diff --git a/Cargo.lock b/Cargo.lock index ecb8110..7d69414 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,18 +4,18 @@ version = 3 [[package]] name = "addr2line" -version = "0.19.0" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a76fd60b23679b7d19bd066031410fb7e458ccc5e958eb5c325888ce4baedc97" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" dependencies = [ "gimli", ] [[package]] -name = "adler" -version = "1.0.2" +name = "adler2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" +checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" [[package]] name = "aho-corasick" @@ -28,9 +28,9 @@ dependencies = [ [[package]] name = "allocator-api2" -version = "0.2.18" +version = "0.2.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" +checksum = "45862d1c77f2228b9e10bc609d5bc203d86ebc9b87ad8d5d5167a6c9abf739d9" [[package]] name = "android-tzdata" @@ -49,29 +49,14 @@ dependencies = [ [[package]] name = "anstream" -version = "0.2.6" +version = "0.6.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "342258dd14006105c2b75ab1bd7543a03bdf0cfc94383303ac212a04939dff6f" +checksum = "8acc5369981196006228e28809f761875c0327210a891e941f4c683b3a99529b" dependencies = [ - "anstyle 0.3.5", - "anstyle-parse 0.1.1", - "anstyle-wincon 0.2.0", - "concolor-override", - "concolor-query", - "is-terminal", - "utf8parse", -] - -[[package]] -name = "anstream" -version = "0.6.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23a1e53f0f5d86382dafe1cf314783b2044280f406e7e1506368220ad11b1338" -dependencies = [ - "anstyle 1.0.9", - "anstyle-parse 0.2.6", + "anstyle", + "anstyle-parse", "anstyle-query", - "anstyle-wincon 3.0.6", + "anstyle-wincon", "colorchoice", "is_terminal_polyfill", "utf8parse", @@ -79,24 +64,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "0.3.5" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23ea9e81bd02e310c216d080f6223c179012256e5151c41db88d12c88a1684d2" - -[[package]] -name = "anstyle" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8365de52b16c035ff4fcafe0092ba9390540e3e352870ac09933bebcaa2c8c56" - -[[package]] -name = "anstyle-parse" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7d1bb534e9efed14f3e5f44e7dd1a4f709384023a4165199a4241e18dff0116" -dependencies = [ - "utf8parse", -] +checksum = "55cc3b69f167a1ef2e161439aa98aed94e6028e5f9a59be9a6ffb47aef1651f9" [[package]] name = "anstyle-parse" @@ -116,23 +86,13 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "anstyle-wincon" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3127af6145b149f3287bb9a0d10ad9c5692dba8c53ad48285e5bec4063834fa" -dependencies = [ - "anstyle 0.3.5", - "windows-sys 0.45.0", -] - [[package]] name = "anstyle-wincon" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2109dbce0e72be3ec00bed26e6a7479ca384ad226efdd66db8fa2e3a38c83125" dependencies = [ - "anstyle 1.0.9", + "anstyle", "windows-sys 0.59.0", ] @@ -164,7 +124,7 @@ version = "2.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1835b7f27878de8525dc71410b5a31cdcc5f230aed5ba5df968e09c201b23d" dependencies = [ - "anstyle 1.0.9", + "anstyle", "bstr", "doc-comment", "libc", @@ -182,9 +142,15 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -193,24 +159,24 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "backtrace" -version = "0.3.67" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233d376d6d185f2a3093e58f283f60f880315b6c60075b01f36b3b85154564ca" +checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", - "cc", "cfg-if", "libc", "miniz_oxide", "object", "rustc-demangle", + "windows-targets 0.52.6", ] [[package]] name = "base64" -version = "0.21.7" +version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bimap" @@ -229,9 +195,9 @@ checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" [[package]] name = "bstr" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40723b8fb387abc38f4f4a37c09073622e41dd12327033091ef8950659e6dc0c" +checksum = "1a68f1f47cdf0ec8ee4b941b2eee2a80cb796db73118c0dd09ac63fbe405be22" dependencies = [ "memchr", "regex-automata", @@ -279,9 +245,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.1.31" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" +checksum = "fd9de9f2205d5ef3fd67e685b0df337994ddd4495e2a28d185500d0e1edfea47" dependencies = [ "shlex", ] @@ -329,8 +295,8 @@ version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ - "anstream 0.6.17", - "anstyle 1.0.9", + "anstream", + "anstyle", "clap_lex", "strsim", ] @@ -353,14 +319,14 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] name = "clap_lex" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" +checksum = "afb84c814227b90d6895e01398aee0d8033c00e7466aca416fb6a8e0eb19d8a7" [[package]] name = "colorchoice" @@ -392,24 +358,6 @@ dependencies = [ "static_assertions", ] -[[package]] -name = "concolor-override" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a4925288e39d5923e024781971aab940995fa31bab3ffceebbadfc87591e90" -dependencies = [ - "colorchoice", -] - -[[package]] -name = "concolor-query" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d11d52c3d7ca2e6d0040212be9e4dbbcd78b6447f535b6b561f449427944cf" -dependencies = [ - "windows-sys 0.45.0", -] - [[package]] name = "confy" version = "0.6.0" @@ -422,6 +370,19 @@ dependencies = [ "thiserror", ] +[[package]] +name = "console" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "unicode-width 0.1.14", + "windows-sys 0.52.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -438,22 +399,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "crossterm" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" -dependencies = [ - "bitflags", - "crossterm_winapi", - "libc", - "mio 0.8.11", - "parking_lot", - "signal-hook", - "signal-hook-mio", - "winapi", -] - [[package]] name = "crossterm" version = "0.28.1" @@ -579,6 +524,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -597,6 +553,12 @@ version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" +[[package]] +name = "encode_unicode" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -624,9 +586,9 @@ dependencies = [ [[package]] name = "fastrand" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" +checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" [[package]] name = "fnv" @@ -726,7 +688,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -778,9 +740,9 @@ dependencies = [ [[package]] name = "gimli" -version = "0.27.3" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c80984affa11d98d1b88b66ac8853f143217b399d3c74116778ff8fdb4ed2e" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" @@ -799,7 +761,26 @@ dependencies = [ "futures-core", "futures-sink", "futures-util", - "http", + "http 0.2.12", + "indexmap 2.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccae279728d634d083c00f6099cb58f01cc99c145b84b8be2f6c74618d79922e" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.1.0", "indexmap 2.6.0", "slab", "tokio", @@ -815,9 +796,9 @@ checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ "allocator-api2", "equivalent", @@ -836,12 +817,6 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - [[package]] name = "http" version = "0.2.12" @@ -853,6 +828,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-body" version = "0.4.6" @@ -860,7 +846,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" dependencies = [ "bytes", - "http", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.1.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -878,12 +887,12 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "human-panic" -version = "1.1.3" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6557b29bbdc9d6c7a5cdbe2962e78eaf48115e8d55b0b62282956981c1f605" +checksum = "80b84a66a325082740043a6c28bbea400c129eac0d3a27673a1de971e44bf1f7" dependencies = [ - "anstream 0.2.6", - "anstyle 0.3.5", + "anstream", + "anstyle", "backtrace", "os_info", "serde", @@ -908,9 +917,9 @@ dependencies = [ "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -923,16 +932,75 @@ dependencies = [ ] [[package]] -name = "hyper-tls" -version = "0.5.0" +name = "hyper" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +checksum = "97818827ef4f364230e16705d4706e2897df2bb60617d6ca15d598025a3c481f" dependencies = [ "bytes", - "hyper", + "futures-channel", + "futures-util", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" +dependencies = [ + "futures-util", + "http 1.1.0", + "hyper 1.5.1", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper 1.5.1", + "hyper-util", "native-tls", "tokio", "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.1", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -959,13 +1027,142 @@ dependencies = [ ] [[package]] -name = "idna" -version = "0.5.0" +name = "icu_collections" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" dependencies = [ - "unicode-bidi", - "unicode-normalization", + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daca1df1c957320b2cf139ac61e7bd64fed304c5040df000a745aa1de3b4ef71" +dependencies = [ + "icu_normalizer", + "icu_properties", ] [[package]] @@ -985,7 +1182,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", - "hashbrown 0.15.0", + "hashbrown 0.15.2", +] + +[[package]] +name = "indicatif" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbf675b85ed934d3c67b5c5469701eec7db22689d0a2139d856e0925fa28b281" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width 0.2.0", + "web-time", ] [[package]] @@ -1001,7 +1211,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c" dependencies = [ "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -1010,17 +1220,6 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" -[[package]] -name = "is-terminal" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" -dependencies = [ - "hermit-abi 0.4.0", - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -1038,9 +1237,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.11" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" +checksum = "d75a2a4b1b190afb6f5425f10f6a8f959d2ea0b9c2b1d79553551850539e4674" [[package]] name = "js-sys" @@ -1059,9 +1258,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.161" +version = "0.2.165" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +checksum = "fcb4d3d38eab6c5239a362fa8bae48c03baf980a6e7079f063942d563ef3533e" [[package]] name = "libredox" @@ -1085,6 +1284,12 @@ version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" + [[package]] name = "lock_api" version = "0.4.12" @@ -1143,7 +1348,7 @@ version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" dependencies = [ - "hashbrown 0.15.0", + "hashbrown 0.15.2", ] [[package]] @@ -1161,15 +1366,17 @@ dependencies = [ "clap_complete", "colored", "confy", - "crossterm 0.27.0", + "crossterm", "ctrlc", "derivative", "dirs-next", "human-panic", + "indicatif", "indoc", "itertools", "log", "log4rs", + "managarr-tree-widget", "mockall", "mockito", "pretty_assertions", @@ -1187,6 +1394,16 @@ dependencies = [ "urlencoding", ] +[[package]] +name = "managarr-tree-widget" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae8e5f28f9581dcddb67e4741a96231752dafb997224cae6d42c75db29eb5af" +dependencies = [ + "ratatui", + "unicode-width 0.2.0", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1201,11 +1418,11 @@ checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "miniz_oxide" -version = "0.6.2" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b275950c28b37e794e8c55d88aeb5e139d0ce23fdbbeda68f8d7174abdf9e8fa" +checksum = "e2d80299ef12ff69b16a84bb182e3b9df68b5a91574d3d4fa6e41b65deec4df1" dependencies = [ - "adler", + "adler2", ] [[package]] @@ -1215,7 +1432,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", - "log", "wasi", "windows-sys 0.48.0", ] @@ -1226,7 +1442,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", "log", "wasi", @@ -1256,7 +1472,7 @@ dependencies = [ "cfg-if", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -1268,7 +1484,7 @@ dependencies = [ "assert-json-diff", "colored", "futures", - "hyper", + "hyper 0.14.31", "lazy_static", "log", "rand", @@ -1329,7 +1545,7 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" dependencies = [ - "hermit-abi 0.3.9", + "hermit-abi", "libc", ] @@ -1343,10 +1559,16 @@ dependencies = [ ] [[package]] -name = "object" -version = "0.30.4" +name = "number_prefix" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b4680b86d9cfafba8fc491dc9b6df26b68cf40e9e6cd73909194759a63c385" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "object" +version = "0.36.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] @@ -1380,7 +1602,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -1489,6 +1711,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" +[[package]] +name = "portable-atomic" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280dc24453071f1b63954171985a0b0d30058d287960968b9b2aca264c8d4ee6" + [[package]] name = "powerfmt" version = "0.2.0" @@ -1510,7 +1738,7 @@ version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e9086cc7640c29a356d1a29fd134380bee9d8f79a17410aa76e7ad295f42c97" dependencies = [ - "anstyle 1.0.9", + "anstyle", "difflib", "predicates-core", ] @@ -1544,10 +1772,19 @@ dependencies = [ ] [[package]] -name = "proc-macro2" -version = "1.0.89" +name = "proc-macro-crate" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "8ecf48c7ca261d60b74ab1a7b20da18bede46776b2e55535cb958eb595c5fa7b" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] @@ -1593,24 +1830,24 @@ dependencies = [ [[package]] name = "ratatui" -version = "0.28.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ba6a365afbe5615999275bea2446b970b10a41102500e27ce7678d50d978303" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ "bitflags", "cassowary", "compact_str", - "crossterm 0.28.1", + "crossterm", + "indoc", "instability", "itertools", "lru", "paste", "strum", - "strum_macros", "time", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.2.0", ] [[package]] @@ -1647,9 +1884,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368758f23274712b504848e9d5a6f010445cc8b87a7cdb4d7cbee666c1288da3" +checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908" dependencies = [ "aho-corasick", "memchr", @@ -1670,20 +1907,23 @@ checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] name = "reqwest" -version = "0.11.14" +version = "0.12.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21eed90ec8570952d53b772ecf8f206aa1ec9a3d76b2521c56c42973f2d91ee9" +checksum = "a77c62af46e79de0a562e1a9849205ffcb7fc1238876e9bd743357570e04046f" dependencies = [ "base64", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2", - "http", - "http-body", - "hyper", + "h2 0.4.7", + "http 1.1.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.5.1", + "hyper-rustls", "hyper-tls", + "hyper-util", "ipnet", "js-sys", "log", @@ -1692,9 +1932,12 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls-pemfile", "serde", "serde_json", "serde_urlencoded", + "sync_wrapper", + "system-configuration", "tokio", "tokio-native-tls", "tower-service", @@ -1702,14 +1945,29 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "winreg", + "windows-registry", +] + +[[package]] +name = "ring" +version = "0.17.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c17fa4cb658e3583423e915b9f3acc01cceaee1860e33d59ebae66adc3a2dc0d" +dependencies = [ + "cc", + "cfg-if", + "getrandom", + "libc", + "spin", + "untrusted", + "windows-sys 0.52.0", ] [[package]] name = "rstest" -version = "0.18.2" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97eeab2f3c0a199bc4be135c36c924b6590b88c377d416494288c14f2db30199" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" dependencies = [ "futures", "futures-timer", @@ -1719,18 +1977,19 @@ dependencies = [ [[package]] name = "rstest_macros" -version = "0.18.2" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d428f8247852f894ee1be110b375111b586d4fa431f6c46e64ba5a0dcccbe605" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" dependencies = [ "cfg-if", "glob", + "proc-macro-crate", "proc-macro2", "quote", "regex", "relative-path", "rustc_version", - "syn 2.0.85", + "syn 2.0.89", "unicode-ident", ] @@ -1751,9 +2010,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.38" +version = "0.38.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa260229e6538e52293eeb577aabd09945a09d6d9cc0fc550ed7529056c2e32a" +checksum = "d7f649912bc1495e167a6edee79151c84b1bad49748cb4f1f1167f459f6224f6" dependencies = [ "bitflags", "errno", @@ -1762,6 +2021,45 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.18" @@ -1776,9 +2074,9 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "schannel" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" dependencies = [ "windows-sys 0.59.0", ] @@ -1804,9 +2102,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "fa39c7303dc58b5543c94d22c1766b0d31f2ee58306363ea622b10bbc075eaa2" dependencies = [ "core-foundation-sys", "libc", @@ -1845,7 +2143,7 @@ checksum = "de523f781f095e28fa605cdce0f8307e451cc0fd14e2eb4cd2e98a355b147766" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -1928,7 +2226,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio 0.8.11", "mio 1.0.2", "signal-hook", ] @@ -1973,6 +2270,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + [[package]] name = "static_assertions" version = "1.1.0" @@ -2004,9 +2313,15 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.85", + "syn 2.0.89", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -2020,9 +2335,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.85" +version = "2.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" +checksum = "44d46482f1c1c87acd84dea20c1bf5ebff4c757009ed6bf19cfd36fb10e92c4e" dependencies = [ "proc-macro2", "quote", @@ -2030,10 +2345,51 @@ dependencies = [ ] [[package]] -name = "tempfile" -version = "3.13.0" +name = "sync_wrapper" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", +] + +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cce251fcbc87fac86a866eeb0d6c2d536fc16d06f184bb61aeae11aa4cee0c" dependencies = [ "cfg-if", "fastrand", @@ -2050,22 +2406,22 @@ checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" [[package]] name = "thiserror" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.65" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -2100,20 +2456,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" [[package]] -name = "tinyvec" -version = "1.8.0" +name = "tinystr" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445e881f4f6d382d5f27c034e25eb92edd7c784ceab92a0937db7f2e9471b938" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" dependencies = [ - "tinyvec_macros", + "displaydoc", + "zerovec", ] -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - [[package]] name = "tokio" version = "1.36.0" @@ -2141,7 +2492,7 @@ checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", ] [[package]] @@ -2154,6 +2505,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" +dependencies = [ + "rustls", + "rustls-pki-types", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.8" @@ -2170,9 +2532,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.7.8" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd79e69d3b627db300ff956027cc6c3798cef26d22526befdfcd12feeb6d2257" +checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" dependencies = [ "serde", "serde_spanned", @@ -2191,9 +2553,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.19.15" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap 2.6.0", "serde", @@ -2220,9 +2582,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.32" +version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54" +checksum = "e672c95779cf947c5311f83787af4fa8fffd12fb27e4993211a84bdfd9610f9c" dependencies = [ "once_cell", ] @@ -2242,26 +2604,11 @@ dependencies = [ "unsafe-any-ors", ] -[[package]] -name = "unicode-bidi" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" - [[package]] name = "unicode-ident" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" - -[[package]] -name = "unicode-normalization" -version = "0.1.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5033c97c4262335cded6d6fc3e5c18ab755e1a3dc96376350f3d8e9f009ad956" -dependencies = [ - "tinyvec", -] +checksum = "adb9e6ca4f869e1180728b7950e35922a7fc6397f7b641499e8f3ef06e50dc83" [[package]] name = "unicode-segmentation" @@ -2277,7 +2624,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -2286,6 +2633,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unsafe-any-ors" version = "1.0.0" @@ -2302,10 +2655,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" [[package]] -name = "url" -version = "2.5.2" +name = "untrusted" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" dependencies = [ "form_urlencoded", "idna", @@ -2318,6 +2677,18 @@ version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8db7427f936968176eaa7cdf81b7f98b980b18495ec28f1b5791ac3bfe3eea9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2385,7 +2756,7 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", "wasm-bindgen-shared", ] @@ -2419,7 +2790,7 @@ checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2440,6 +2811,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2472,12 +2853,33 @@ dependencies = [ ] [[package]] -name = "windows-sys" -version = "0.45.0" +name = "windows-registry" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" dependencies = [ - "windows-targets 0.42.2", + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", ] [[package]] @@ -2507,21 +2909,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -2553,12 +2940,6 @@ dependencies = [ "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2571,12 +2952,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2589,12 +2964,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2613,12 +2982,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2631,12 +2994,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2649,12 +3006,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2667,12 +3018,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2687,21 +3032,24 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.5.40" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] [[package]] -name = "winreg" -version = "0.10.1" +name = "write16" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" [[package]] name = "yaml-rust" @@ -2718,6 +3066,30 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09041cd90cf85f7f8b2df60c646f853b7f535ce68f85244eb6731cf89fa498ec" +[[package]] +name = "yoke" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "120e6aef9aa629e3d4f52dc8cc43a015c7724194c97dfaf45180d2daf2b77f40" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.7.35" @@ -2736,5 +3108,54 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.85", + "syn 2.0.89", +] + +[[package]] +name = "zerofrom" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff3ee08c995dee1859d998dea82f7374f2826091dd9cd47def953cae446cd2e" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" + +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.89", ] diff --git a/Cargo.toml b/Cargo.toml index 1d9ca4d..21e60bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,20 +15,20 @@ exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"] [dependencies] anyhow = "1.0.68" -backtrace = "0.3.67" +backtrace = "0.3.74" bimap = { version = "0.6.3", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] } confy = { version = "0.6.0", default-features = false, features = [ "yaml_conf", ] } -crossterm = "0.27.0" +crossterm = "0.28.1" derivative = "2.2.0" -human-panic = "1.1.3" +human-panic = "2.0.2" indoc = "2.0.0" log = "0.4.17" log4rs = { version = "1.2.0", features = ["file_appender"] } regex = "1.11.1" -reqwest = { version = "0.11.14", features = ["json"] } +reqwest = { version = "0.12.9", features = ["json"] } serde_yaml = "0.9.16" serde_json = "1.0.91" serde = { version = "1.0.214", features = ["derive"] } @@ -36,7 +36,7 @@ strum = { version = "0.26.3", features = ["derive"] } strum_macros = "0.26.4" tokio = { version = "1.36.0", features = ["full"] } tokio-util = "0.7.8" -ratatui = { version = "0.28.0", features = ["all-widgets"] } +ratatui = { version = "0.29.0", features = ["all-widgets"] } urlencoding = "2.1.2" clap = { version = "4.5.20", features = ["derive", "cargo", "env"] } clap_complete = "4.5.33" @@ -45,13 +45,15 @@ ctrlc = "3.4.5" colored = "2.1.0" async-trait = "0.1.83" dirs-next = "2.0.0" +managarr-tree-widget = "0.24.0" +indicatif = "0.17.9" [dev-dependencies] assert_cmd = "2.0.16" mockall = "0.13.0" mockito = "1.0.0" pretty_assertions = "1.3.0" -rstest = "0.18.2" +rstest = "0.23.0" [dev-dependencies.cargo-husky] version = "1" diff --git a/README.md b/README.md index 8a81649..dd5b412 100644 --- a/README.md +++ b/README.md @@ -51,31 +51,59 @@ docker run --rm -it -v ~/.config/managarr/config.yml:/root/.config/managarr/conf You can also clone this repo and run `make docker` to build a docker image locally and run it using the above command. -Please note that you will need to create and popular your configuration file first before starting the container. Otherwise the container will fail to start. +Please note that you will need to create and popular your configuration file first before starting the container. Otherwise, the container will fail to start. ## Features +Key: + +| Symbol | Status | +|--------------------|-----------| +| :white_check_mark: | Supported | +| :x: | Missing | +| :clock3: | Planned | +| :no_entry_sign: | Won't Add | ### Radarr -- [x] View your library, downloads, collections, and blocklist -- [x] View details of a specific movie including description, history, downloaded file info, or the credits -- [x] View details of any collection and the movies in them -- [x] View your host and security configs from the CLI to programmatically fetch the API token, among other settings -- [x] Search your library or collections -- [x] Add movies to your library -- [x] Delete movies, downloads, and indexers -- [x] Trigger automatic searches for movies -- [x] Trigger refresh and disk scan for movies, downloads, and collections -- [x] Manually search for movies -- [x] Edit your movies, collections, and indexers -- [x] Manage your tags -- [x] Manage your root folders -- [x] Manage your blocklist -- [x] View and browse logs, tasks, events queues, and updates -- [x] Manually trigger scheduled tasks +| TUI | CLI | Feature | +|--------------------|--------------------|----------------------------------------------------------------------------------------------------------------| +| :white_check_mark: | :white_check_mark: | View your library, downloads, collections, and blocklist | +| :white_check_mark: | :white_check_mark: | View details of a specific movie including description, history, downloaded file info, or the credits | +| :white_check_mark: | :white_check_mark: | View details of any collection and the movies in them | +| :no_entry_sign: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| :white_check_mark: | :white_check_mark: | Search your library or collections | +| :white_check_mark: | :white_check_mark: | Add movies to your library | +| :white_check_mark: | :white_check_mark: | Delete movies, downloads, and indexers | +| :white_check_mark: | :white_check_mark: | Trigger automatic searches for movies | +| :white_check_mark: | :white_check_mark: | Trigger refresh and disk scan for movies, downloads, and collections | +| :white_check_mark: | :white_check_mark: | Manually search for movies | +| :white_check_mark: | :white_check_mark: | Edit your movies, collections, and indexers | +| :white_check_mark: | :white_check_mark: | Manage your tags | +| :white_check_mark: | :white_check_mark: | Manage your root folders | +| :white_check_mark: | :white_check_mark: | Manage your blocklist | +| :white_check_mark: | :white_check_mark: | View and browse logs, tasks, events queues, and updates | +| :white_check_mark: | :white_check_mark: | Manually trigger scheduled tasks | ### Sonarr -- [ ] Support for Sonarr + +| TUI | CLI | Feature | +|----------|--------------------|--------------------------------------------------------------------------------------------------------------------| +| :clock3: | :white_check_mark: | View your library, downloads, blocklist, episodes | +| :clock3: | :white_check_mark: | View details of a specific series, or episode including description, history, downloaded file info, or the credits | +| :clock3: | :white_check_mark: | View your host and security configs from the CLI to programmatically fetch the API token, among other settings | +| :clock3: | :white_check_mark: | Search your library | +| :clock3: | :white_check_mark: | Add series to your library | +| :clock3: | :white_check_mark: | Delete series, downloads, indexers, root folders, and episode files | +| :clock3: | :white_check_mark: | Mark history events as failed | +| :clock3: | :white_check_mark: | Trigger automatic searches for series, seasons, or episodes | +| :clock3: | :white_check_mark: | Trigger refresh and disk scan for series and downloads | +| :clock3: | :white_check_mark: | Manually search for series, seasons, or episodes | +| :clock3: | :white_check_mark: | Edit your series and indexers | +| :clock3: | :white_check_mark: | Manage your tags | +| :clock3: | :white_check_mark: | Manage your root folders | +| :clock3: | :white_check_mark: | Manage your blocklist | +| :clock3: | :white_check_mark: | View and browse logs, tasks, events queues, and updates | +| :clock3: | :white_check_mark: | Manually trigger scheduled tasks | ### Readarr @@ -107,13 +135,13 @@ Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your All management features available in the TUI are also available in the CLI. However, the CLI is equipped with additional features to allow for more advanced usage and automation. -The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your library. +The CLI can be helpful for automating tasks or for use in scripts. For example, you can use the CLI to trigger a search for a movie, or to add a movie to your Radarr library. To see all available commands, simply run `managarr --help`: ```shell $ managarr --help -managarr 0.2.1 +managarr 0.3.0 Alex Clarke A TUI and CLI to manage your Servarrs @@ -122,43 +150,48 @@ Usage: managarr [OPTIONS] [COMMAND] Commands: radarr Commands for manging your Radarr instance + sonarr Commands for manging your Sonarr instance completions Generate shell completions for the Managarr CLI + tail-logs Tail Managarr logs help Print this message or the help of the given subcommand(s) Options: - --config The Managarr configuration file to use - -h, --help Print help - -V, --version Print version + --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] + --config The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] + -h, --help Print help + -V, --version Print version ``` -All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Radarr, you would run: +All subcommands also have detailed help menus to show you how to use them. For example, to see all available commands for Sonarr, you would run: ```shell -$ managarr radarr --help -Commands for manging your Radarr instance +$ managarr sonarr --help +Commands for manging your Sonarr instance -Usage: managarr radarr [OPTIONS] +Usage: managarr sonarr [OPTIONS] Commands: - add Commands to add or create new resources within your Radarr instance - delete Commands to delete resources from your Radarr instance - edit Commands to edit resources in your Radarr instance - get Commands to fetch details of the resources in your Radarr instance - list Commands to list attributes from your Radarr instance - refresh Commands to refresh the data in your Radarr instance - clear-blocklist Clear the blocklist - download-release Manually download the given release for the specified movie ID - manual-search Trigger a manual search of releases for the movie with the given ID - search-new-movie Search for a new film to add to Radarr - start-task Start the specified Radarr task - test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}' - test-all-indexers Test all indexers - trigger-automatic-search Trigger an automatic search for the movie with the specified ID - help Print this message or the help of the given subcommand(s) + add Commands to add or create new resources within your Sonarr instance + delete Commands to delete resources from your Sonarr instance + edit Commands to edit resources in your Sonarr instance + get Commands to fetch details of the resources in your Sonarr instance + download Commands to download releases in your Sonarr instance + list Commands to list attributes from your Sonarr instance + refresh Commands to refresh the data in your Sonarr instance + manual-search Commands to manually search for releases + trigger-automatic-search Commands to trigger automatic searches for releases of different resources in your Sonarr instance + clear-blocklist Clear the blocklist + mark-history-item-as-failed Mark the Sonarr history item with the given ID as 'failed' + search-new-series Search for a new series to add to Sonarr + start-task Start the specified Sonarr task + test-indexer Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}' + test-all-indexers Test all Sonarr indexers + help Print this message or the help of the given subcommand(s) Options: - --config The Managarr configuration file to use - -h, --help Print help + --disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=] + --config The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=] + -h, --help Print help ``` **Pro Tip:** The CLI is even more powerful and useful when used in conjunction with the `jq` CLI tool. This allows you to parse the JSON response from the Managarr CLI and use it in your scripts; For example, to extract the `movieId` of the movie "Ad Astra", you would run: @@ -172,7 +205,7 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id' Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878), but all servers will require you to input the API token. -The configuration file is located somewhere different for each OS +The configuration file is located somewhere different for each OS. ### Linux ``` @@ -238,9 +271,11 @@ tautulli: ## Environment Variables Managarr supports using environment variables on startup so you don't have to always specify certain flags: -| Variable | Description | Equivalent Flag | -| --------------------------------------- | -------------------------------- | -------------------------------- | -| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | +| Variable | Description | Equivalent Flag | +|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------| +| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | +| `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` | +|-----------------------------------------|--------------------------------------------------------------------------------|----------------------------------| ## Track My Progress for the Beta release (With Sonarr Support!) Progress for the beta release can be followed on my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr) diff --git a/src/app/app_tests.rs b/src/app/app_tests.rs index 82af22a..2fb02a0 100644 --- a/src/app/app_tests.rs +++ b/src/app/app_tests.rs @@ -5,9 +5,10 @@ mod tests { use tokio::sync::mpsc; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; - use crate::app::{App, Data, RadarrConfig, DEFAULT_ROUTE}; + use crate::app::{App, AppConfig, Data, ServarrConfig, DEFAULT_ROUTE}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; - use crate::models::{HorizontallyScrollableText, Route, TabRoute}; + use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; + use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkEvent; @@ -34,7 +35,7 @@ mod tests { }, TabRoute { title: "Sonarr", - route: Route::Sonarr, + route: ActiveSonarrBlock::Series.into(), help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)), contextual_help: None, }, @@ -47,6 +48,7 @@ mod tests { assert!(!app.is_routing); assert!(!app.should_refresh); assert!(!app.should_ignore_quit_key); + assert!(!app.cli_mode); } #[test] @@ -126,6 +128,10 @@ mod tests { version: "test".to_owned(), ..RadarrData::default() }, + sonarr_data: SonarrData { + version: "test".to_owned(), + ..SonarrData::default() + }, }, ..App::default() }; @@ -135,6 +141,7 @@ mod tests { assert_eq!(app.tick_count, 0); assert_eq!(app.error, HorizontallyScrollableText::default()); assert!(app.data.radarr_data.version.is_empty()); + assert!(app.data.sonarr_data.version.is_empty()); } #[test] @@ -206,7 +213,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -248,13 +255,21 @@ mod tests { } #[test] - fn test_radarr_config_default() { - let radarr_config = RadarrConfig::default(); + fn test_app_config_default() { + let app_config = AppConfig::default(); - assert_eq!(radarr_config.host, Some("localhost".to_string())); - assert_eq!(radarr_config.port, Some(7878)); - assert_eq!(radarr_config.uri, None); - assert!(radarr_config.api_token.is_empty()); - assert_eq!(radarr_config.ssl_cert_path, None); + assert!(app_config.radarr.is_none()); + assert!(app_config.sonarr.is_none()); + } + + #[test] + fn test_servarr_config_default() { + let servarr_config = ServarrConfig::default(); + + assert_eq!(servarr_config.host, Some("localhost".to_string())); + assert_eq!(servarr_config.port, None); + assert_eq!(servarr_config.uri, None); + assert!(servarr_config.api_token.is_empty()); + assert_eq!(servarr_config.ssl_cert_path, None); } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 87faa8e..f401e1d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -8,7 +8,9 @@ use tokio::sync::mpsc::Sender; use tokio_util::sync::CancellationToken; use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; +use crate::cli::Command; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; +use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState}; use crate::network::NetworkEvent; @@ -35,6 +37,7 @@ pub struct App<'a> { pub is_loading: bool, pub should_refresh: bool, pub should_ignore_quit_key: bool, + pub cli_mode: bool, pub config: AppConfig, pub data: Data<'a>, } @@ -151,7 +154,7 @@ impl<'a> Default for App<'a> { }, TabRoute { title: "Sonarr", - route: Route::Sonarr, + route: ActiveSonarrBlock::Series.into(), help: format!("{} ", build_context_clue_string(&SERVARR_CONTEXT_CLUES)), contextual_help: None, }, @@ -163,6 +166,7 @@ impl<'a> Default for App<'a> { is_routing: false, should_refresh: false, should_ignore_quit_key: false, + cli_mode: false, config: AppConfig::default(), data: Data::default(), } @@ -172,25 +176,49 @@ impl<'a> Default for App<'a> { #[derive(Default)] pub struct Data<'a> { pub radarr_data: RadarrData<'a>, + pub sonarr_data: SonarrData, } -pub trait ServarrConfig { - fn validate(&self); -} - -#[derive(Debug, Deserialize, Serialize, Default)] +#[derive(Debug, Deserialize, Serialize, Default, Clone)] pub struct AppConfig { - pub radarr: RadarrConfig, + pub radarr: Option, + pub sonarr: Option, } -impl ServarrConfig for AppConfig { - fn validate(&self) { - self.radarr.validate(); +impl AppConfig { + pub fn validate(&self) { + if let Some(radarr_config) = &self.radarr { + radarr_config.validate(); + } + + if let Some(sonarr_config) = &self.sonarr { + sonarr_config.validate(); + } + } + + pub fn verify_config_present_for_cli(&self, command: &Command) { + let msg = |servarr: &str| { + log_and_print_error(format!( + "{} configuration missing; Unable to run any {} commands.", + servarr, servarr + )) + }; + match command { + Command::Radarr(_) if self.radarr.is_none() => { + msg("Radarr"); + process::exit(1); + } + Command::Sonarr(_) if self.sonarr.is_none() => { + msg("Sonarr"); + process::exit(1); + } + _ => (), + } } } -#[derive(Debug, Deserialize, Serialize)] -pub struct RadarrConfig { +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct ServarrConfig { pub host: Option, pub port: Option, pub uri: Option, @@ -198,20 +226,20 @@ pub struct RadarrConfig { pub ssl_cert_path: Option, } -impl ServarrConfig for RadarrConfig { +impl ServarrConfig { fn validate(&self) { if self.host.is_none() && self.uri.is_none() { - log_and_print_error("'host' or 'uri' is required for Radarr configuration".to_owned()); + log_and_print_error("'host' or 'uri' is required for configuration".to_owned()); process::exit(1); } } } -impl Default for RadarrConfig { +impl Default for ServarrConfig { fn default() -> Self { - RadarrConfig { + ServarrConfig { host: Some("localhost".to_string()), - port: Some(7878), + port: None, uri: None, api_token: "".to_string(), ssl_cert_path: None, diff --git a/src/app/radarr/mod.rs b/src/app/radarr/mod.rs index 3ffc43a..1c7f181 100644 --- a/src/app/radarr/mod.rs +++ b/src/app/radarr/mod.rs @@ -179,7 +179,7 @@ impl<'a> App<'a> { .dispatch_network_event(RadarrEvent::GetDownloads.into()) .await; self - .dispatch_network_event(RadarrEvent::GetOverview.into()) + .dispatch_network_event(RadarrEvent::GetDiskSpace.into()) .await; self .dispatch_network_event(RadarrEvent::GetStatus.into()) diff --git a/src/app/radarr/radarr_tests.rs b/src/app/radarr/radarr_tests.rs index 286d21f..901ca12 100644 --- a/src/app/radarr/radarr_tests.rs +++ b/src/app/radarr/radarr_tests.rs @@ -6,7 +6,7 @@ mod tests { use crate::app::radarr::ActiveRadarrBlock; use crate::app::App; - use crate::models::radarr_models::{Collection, CollectionMovie, Credit, Release}; + use crate::models::radarr_models::{Collection, CollectionMovie, Credit, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::network::radarr_network::RadarrEvent; @@ -430,7 +430,7 @@ mod tests { let mut movie_details_modal = MovieDetailsModal::default(); movie_details_modal .movie_releases - .set_items(vec![Release::default()]); + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); app @@ -510,7 +510,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), @@ -543,7 +543,7 @@ mod tests { ); assert_eq!( sync_network_rx.recv().await.unwrap(), - RadarrEvent::GetOverview.into() + RadarrEvent::GetDiskSpace.into() ); assert_eq!( sync_network_rx.recv().await.unwrap(), diff --git a/src/cli/cli_tests.rs b/src/cli/cli_tests.rs index c080b00..f58b6cd 100644 --- a/src/cli/cli_tests.rs +++ b/src/cli/cli_tests.rs @@ -10,19 +10,28 @@ mod tests { use crate::{ app::App, - cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand}, + cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, models::{ - radarr_models::{BlocklistItem, BlocklistResponse, RadarrSerdeable}, + radarr_models::{ + BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, + RadarrSerdeable, + }, + sonarr_models::{ + BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, + SonarrSerdeable, + }, Serdeable, }, - network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, + network::{ + radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent, + }, Cli, }; use pretty_assertions::assert_eq; - #[test] - fn test_radarr_subcommand_requires_subcommand() { - let result = Cli::command().try_get_matches_from(["managarr", "radarr"]); + #[rstest] + fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) { + let result = Cli::command().try_get_matches_from(["managarr", subcommand]); assert!(result.is_err()); assert_eq!( @@ -39,6 +48,13 @@ mod tests { assert!(result.is_ok()); } + #[test] + fn test_sonarr_subcommand_delegates_to_sonarr() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]); + + assert!(result.is_ok()); + } + #[test] fn test_completions_requires_argument() { let result = Cli::command().try_get_matches_from(["managarr", "completions"]); @@ -106,8 +122,8 @@ mod tests { .times(1) .returning(|_| { Ok(Serdeable::Radarr(RadarrSerdeable::BlocklistResponse( - BlocklistResponse { - records: vec![BlocklistItem::default()], + RadarrBlocklistResponse { + records: vec![RadarrBlocklistItem::default()], }, ))) }); @@ -121,9 +137,40 @@ mod tests { ))) }); let app_arc = Arc::new(Mutex::new(App::default())); - let claer_blocklist_command = RadarrCommand::ClearBlocklist.into(); + let clear_blocklist_command = RadarrCommand::ClearBlocklist.into(); - let result = handle_command(&app_arc, claer_blocklist_command, &mut mock_network).await; + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_cli_handler_delegates_sonarr_commands_to_the_sonarr_cli_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse( + SonarrBlocklistResponse { + records: vec![SonarrBlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let clear_blocklist_command = SonarrCommand::ClearBlocklist.into(); + + let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; assert!(result.is_ok()); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 66c6bb2..5eb9c37 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,11 +4,13 @@ use anyhow::Result; use clap::{command, Subcommand}; use clap_complete::Shell; use radarr::{RadarrCliHandler, RadarrCommand}; +use sonarr::{SonarrCliHandler, SonarrCommand}; use tokio::sync::Mutex; use crate::{app::App, network::NetworkTrait}; pub mod radarr; +pub mod sonarr; #[cfg(test)] #[path = "cli_tests.rs"] @@ -19,6 +21,9 @@ pub enum Command { #[command(subcommand, about = "Commands for manging your Radarr instance")] Radarr(RadarrCommand), + #[command(subcommand, about = "Commands for manging your Sonarr instance")] + Sonarr(SonarrCommand), + #[command( arg_required_else_help = true, about = "Generate shell completions for the Managarr CLI" @@ -37,20 +42,29 @@ pub enum Command { pub trait CliCommandHandler<'a, 'b, T: Into> { fn with(app: &'a Arc>>, command: T, network: &'a mut dyn NetworkTrait) -> Self; - async fn handle(self) -> Result<()>; + async fn handle(self) -> Result; } pub(crate) async fn handle_command( app: &Arc>>, command: Command, network: &mut dyn NetworkTrait, -) -> Result<()> { - if let Command::Radarr(radarr_command) = command { - RadarrCliHandler::with(app, radarr_command, network) - .handle() - .await? - } - Ok(()) +) -> Result { + let result = match command { + Command::Radarr(radarr_command) => { + RadarrCliHandler::with(app, radarr_command, network) + .handle() + .await? + } + Command::Sonarr(sonarr_command) => { + SonarrCliHandler::with(app, sonarr_command, network) + .handle() + .await? + } + _ => String::new(), + }; + + Ok(result) } #[inline] @@ -74,16 +88,3 @@ pub fn mutex_flags_or_default(positive: bool, negative: bool, default_value: boo default_value } } - -#[macro_export] -macro_rules! execute_network_event { - ($self:ident, $event:expr) => { - let resp = $self.network.handle_network_event($event.into()).await?; - let json = serde_json::to_string_pretty(&resp)?; - println!("{}", json); - }; - ($self:ident, $event:expr, $happy_output:expr) => { - $self.network.handle_network_event($event.into()).await?; - println!("{}", $happy_output); - }; -} diff --git a/src/cli/radarr/add_command_handler.rs b/src/cli/radarr/add_command_handler.rs index 1e26963..0c35a7e 100644 --- a/src/cli/radarr/add_command_handler.rs +++ b/src/cli/radarr/add_command_handler.rs @@ -7,8 +7,7 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, - models::radarr_models::{AddMovieBody, AddOptions, MinimumAvailability, Monitor}, + models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor}, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -47,7 +46,7 @@ pub enum RadarrAddCommand { default_value_t = MinimumAvailability::default() )] minimum_availability: MinimumAvailability, - #[arg(long, help = "Should Radarr monitor this film")] + #[arg(long, help = "Disable monitoring for this film")] disable_monitoring: bool, #[arg( long, @@ -60,9 +59,9 @@ pub enum RadarrAddCommand { long, help = "What Radarr should monitor", value_enum, - default_value_t = Monitor::default() + default_value_t = MovieMonitor::default() )] - monitor: Monitor, + monitor: MovieMonitor, #[arg( long, help = "Tell Radarr to not start a search for this film once it's added to your library" @@ -106,8 +105,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrAddCommand::Movie { tmdb_id, root_folder_path, @@ -126,24 +125,33 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan minimum_availability: minimum_availability.to_string(), monitored: !disable_monitoring, tags, - add_options: AddOptions { + add_options: AddMovieOptions { monitor: monitor.to_string(), search_for_movie: !no_search_for_movie, }, }; - execute_network_event!(self, RadarrEvent::AddMovie(Some(body))); + let resp = self + .network + .handle_network_event(RadarrEvent::AddMovie(Some(body)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrAddCommand::RootFolder { root_folder_path } => { - execute_network_event!( - self, - RadarrEvent::AddRootFolder(Some(root_folder_path.clone())) - ); + let resp = self + .network + .handle_network_event(RadarrEvent::AddRootFolder(Some(root_folder_path)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrAddCommand::Tag { name } => { - execute_network_event!(self, RadarrEvent::AddTag(name.clone())); + let resp = self + .network + .handle_network_event(RadarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/add_command_handler_tests.rs b/src/cli/radarr/add_command_handler_tests.rs index 8974454..754abd2 100644 --- a/src/cli/radarr/add_command_handler_tests.rs +++ b/src/cli/radarr/add_command_handler_tests.rs @@ -7,9 +7,10 @@ mod tests { radarr::{add_command_handler::RadarrAddCommand, RadarrCommand}, Command, }, - models::radarr_models::{MinimumAvailability, Monitor}, + models::radarr_models::{MinimumAvailability, MovieMonitor}, Cli, }; + use pretty_assertions::assert_eq; #[test] fn test_radarr_add_command_from() { @@ -111,6 +112,8 @@ mod tests { "/test", "--quality-profile-id", "1", + "--tmdb-id", + "1", flag, ]); @@ -187,7 +190,7 @@ mod tests { minimum_availability: MinimumAvailability::default(), disable_monitoring: false, tag: vec![], - monitor: Monitor::default(), + monitor: MovieMonitor::default(), no_search_for_movie: false, }; @@ -219,7 +222,7 @@ mod tests { minimum_availability: MinimumAvailability::default(), disable_monitoring: false, tag: vec![1, 2], - monitor: Monitor::default(), + monitor: MovieMonitor::default(), no_search_for_movie: false, }; @@ -255,7 +258,7 @@ mod tests { minimum_availability: MinimumAvailability::Released, disable_monitoring: true, tag: vec![1, 2], - monitor: Monitor::MovieAndCollection, + monitor: MovieMonitor::MovieAndCollection, no_search_for_movie: true, }; @@ -356,7 +359,7 @@ mod tests { app::App, cli::{radarr::add_command_handler::RadarrAddCommandHandler, CliCommandHandler}, models::{ - radarr_models::{AddMovieBody, AddOptions, RadarrSerdeable}, + radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable}, Serdeable, }, network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, @@ -378,7 +381,7 @@ mod tests { minimum_availability: "released".to_owned(), monitored: false, tags: vec![1, 2], - add_options: AddOptions { + add_options: AddMovieOptions { monitor: "movieAndCollection".to_owned(), search_for_movie: false, }, @@ -403,7 +406,7 @@ mod tests { minimum_availability: MinimumAvailability::Released, disable_monitoring: true, tag: vec![1, 2], - monitor: Monitor::MovieAndCollection, + monitor: MovieMonitor::MovieAndCollection, no_search_for_movie: true, }; diff --git a/src/cli/radarr/delete_command_handler.rs b/src/cli/radarr/delete_command_handler.rs index db26775..2f10c57 100644 --- a/src/cli/radarr/delete_command_handler.rs +++ b/src/cli/radarr/delete_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, models::radarr_models::DeleteMovieParams, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -85,19 +84,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrDeleteCommand::BlocklistItem { blocklist_item_id } => { - execute_network_event!( - self, - RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)) - ); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Download { download_id } => { - execute_network_event!(self, RadarrEvent::DeleteDownload(Some(download_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteDownload(Some(download_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Indexer { indexer_id } => { - execute_network_event!(self, RadarrEvent::DeleteIndexer(Some(indexer_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteIndexer(Some(indexer_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Movie { movie_id, @@ -109,16 +117,28 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrDeleteCommand> for RadarrDeleteComm delete_movie_files: delete_files_from_disk, add_list_exclusion, }; - execute_network_event!(self, RadarrEvent::DeleteMovie(Some(delete_movie_params))); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteMovie(Some(delete_movie_params)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::RootFolder { root_folder_id } => { - execute_network_event!(self, RadarrEvent::DeleteRootFolder(Some(root_folder_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteRootFolder(Some(root_folder_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrDeleteCommand::Tag { tag_id } => { - execute_network_event!(self, RadarrEvent::DeleteTag(tag_id)); + let resp = self + .network + .handle_network_event(RadarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/delete_command_handler_tests.rs b/src/cli/radarr/delete_command_handler_tests.rs index 7e20597..d8971f7 100644 --- a/src/cli/radarr/delete_command_handler_tests.rs +++ b/src/cli/radarr/delete_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Cli, }; use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; #[test] fn test_radarr_delete_command_from() { diff --git a/src/cli/radarr/edit_command_handler.rs b/src/cli/radarr/edit_command_handler.rs index 666828c..0d206d8 100644 --- a/src/cli/radarr/edit_command_handler.rs +++ b/src/cli/radarr/edit_command_handler.rs @@ -7,12 +7,11 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command}, - execute_network_event, models::{ radarr_models::{ - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - MinimumAvailability, RadarrSerdeable, + EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable, }, + servarr_models::EditIndexerParams, Serdeable, }, network::{radarr_network::RadarrEvent, NetworkTrait}, @@ -339,8 +338,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrEditCommand::AllIndexerSettings { allow_hardcoded_subs, disable_allow_hardcoded_subs, @@ -389,11 +388,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH }) .into(), }; - execute_network_event!( - self, - RadarrEvent::EditAllIndexerSettings(Some(params)), - "All indexer settings updated" - ); + self + .network + .handle_network_event(RadarrEvent::EditAllIndexerSettings(Some(params)).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() } } RadarrEditCommand::Collection { @@ -417,11 +418,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH root_folder_path, search_on_add: search_on_add_value, }; - execute_network_event!( - self, - RadarrEvent::EditCollection(Some(edit_collection_params)), - "Collection Updated" - ); + self + .network + .handle_network_event(RadarrEvent::EditCollection(Some(edit_collection_params)).into()) + .await?; + "Collection updated".to_owned() } RadarrEditCommand::Indexer { indexer_id, @@ -458,11 +459,11 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH clear_tags, }; - execute_network_event!( - self, - RadarrEvent::EditIndexer(Some(edit_indexer_params)), - "Indexer updated" - ); + self + .network + .handle_network_event(RadarrEvent::EditIndexer(Some(edit_indexer_params)).into()) + .await?; + "Indexer updated".to_owned() } RadarrEditCommand::Movie { movie_id, @@ -485,14 +486,14 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH clear_tags, }; - execute_network_event!( - self, - RadarrEvent::EditMovie(Some(edit_movie_params)), - "Movie updated" - ); + self + .network + .handle_network_event(RadarrEvent::EditMovie(Some(edit_movie_params)).into()) + .await?; + "Movie Updated".to_owned() } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/edit_command_handler_tests.rs b/src/cli/radarr/edit_command_handler_tests.rs index 3f77649..5fc2a2a 100644 --- a/src/cli/radarr/edit_command_handler_tests.rs +++ b/src/cli/radarr/edit_command_handler_tests.rs @@ -8,6 +8,7 @@ mod tests { Cli, }; use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; #[test] fn test_radarr_edit_command_from() { @@ -809,9 +810,10 @@ mod tests { }, models::{ radarr_models::{ - EditCollectionParams, EditIndexerParams, EditMovieParams, IndexerSettings, - MinimumAvailability, RadarrSerdeable, + EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, + RadarrSerdeable, }, + servarr_models::EditIndexerParams, Serdeable, }, network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, diff --git a/src/cli/radarr/get_command_handler.rs b/src/cli/radarr/get_command_handler.rs index 1c398ab..4fe2830 100644 --- a/src/cli/radarr/get_command_handler.rs +++ b/src/cli/radarr/get_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -72,28 +71,52 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrGetCommand> for RadarrGetCommandHan } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrGetCommand::AllIndexerSettings => { - execute_network_event!(self, RadarrEvent::GetAllIndexerSettings); + let resp = self + .network + .handle_network_event(RadarrEvent::GetAllIndexerSettings.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::HostConfig => { - execute_network_event!(self, RadarrEvent::GetHostConfig); + let resp = self + .network + .handle_network_event(RadarrEvent::GetHostConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieDetails { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieDetails(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::GetMovieDetails(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::MovieHistory { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieHistory(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::GetMovieHistory(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SecurityConfig => { - execute_network_event!(self, RadarrEvent::GetSecurityConfig); + let resp = self + .network + .handle_network_event(RadarrEvent::GetSecurityConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrGetCommand::SystemStatus => { - execute_network_event!(self, RadarrEvent::GetStatus); + let resp = self + .network + .handle_network_event(RadarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/get_command_handler_tests.rs b/src/cli/radarr/get_command_handler_tests.rs index 990e185..e70db37 100644 --- a/src/cli/radarr/get_command_handler_tests.rs +++ b/src/cli/radarr/get_command_handler_tests.rs @@ -1,5 +1,5 @@ #[cfg(test)] -mod test { +mod tests { use clap::error::ErrorKind; use clap::CommandFactory; @@ -7,6 +7,7 @@ mod test { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_get_command_from() { diff --git a/src/cli/radarr/list_command_handler.rs b/src/cli/radarr/list_command_handler.rs index e536d7f..b769033 100644 --- a/src/cli/radarr/list_command_handler.rs +++ b/src/cli/radarr/list_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -25,6 +24,8 @@ pub enum RadarrListCommand { Collections, #[command(about = "List all active downloads in Radarr")] Downloads, + #[command(about = "List disk space details for all provisioned root folders in Radarr")] + DiskSpace, #[command(about = "List all Radarr indexers")] Indexers, #[command(about = "Fetch Radarr logs")] @@ -56,7 +57,7 @@ pub enum RadarrListCommand { RootFolders, #[command(about = "List all Radarr tags")] Tags, - #[command(about = "List tasks")] + #[command(about = "List all Radarr tasks")] Tasks, #[command(about = "List all Radarr updates")] Updates, @@ -87,19 +88,42 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrListCommand::Blocklist => { - execute_network_event!(self, RadarrEvent::GetBlocklist); + let resp = self + .network + .handle_network_event(RadarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Collections => { - execute_network_event!(self, RadarrEvent::GetCollections); + let resp = self + .network + .handle_network_event(RadarrEvent::GetCollections.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Downloads => { - execute_network_event!(self, RadarrEvent::GetDownloads); + let resp = self + .network + .handle_network_event(RadarrEvent::GetDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + RadarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(RadarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Indexers => { - execute_network_event!(self, RadarrEvent::GetIndexers); + let resp = self + .network + .handle_network_event(RadarrEvent::GetIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Logs { events, @@ -113,39 +137,69 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH if output_in_log_format { let log_lines = self.app.lock().await.data.radarr_data.logs.items.clone(); - let json = serde_json::to_string_pretty(&log_lines)?; - println!("{}", json); + serde_json::to_string_pretty(&log_lines)? } else { - let json = serde_json::to_string_pretty(&logs)?; - println!("{}", json); + serde_json::to_string_pretty(&logs)? } } RadarrListCommand::Movies => { - execute_network_event!(self, RadarrEvent::GetMovies); + let resp = self + .network + .handle_network_event(RadarrEvent::GetMovies.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::MovieCredits { movie_id } => { - execute_network_event!(self, RadarrEvent::GetMovieCredits(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::GetMovieCredits(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::QualityProfiles => { - execute_network_event!(self, RadarrEvent::GetQualityProfiles); + let resp = self + .network + .handle_network_event(RadarrEvent::GetQualityProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::QueuedEvents => { - execute_network_event!(self, RadarrEvent::GetQueuedEvents); + let resp = self + .network + .handle_network_event(RadarrEvent::GetQueuedEvents.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::RootFolders => { - execute_network_event!(self, RadarrEvent::GetRootFolders); + let resp = self + .network + .handle_network_event(RadarrEvent::GetRootFolders.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tags => { - execute_network_event!(self, RadarrEvent::GetTags); + let resp = self + .network + .handle_network_event(RadarrEvent::GetTags.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Tasks => { - execute_network_event!(self, RadarrEvent::GetTasks); + let resp = self + .network + .handle_network_event(RadarrEvent::GetTasks.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrListCommand::Updates => { - execute_network_event!(self, RadarrEvent::GetUpdates); + let resp = self + .network + .handle_network_event(RadarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/list_command_handler_tests.rs b/src/cli/radarr/list_command_handler_tests.rs index 4c7446a..ca9aa0f 100644 --- a/src/cli/radarr/list_command_handler_tests.rs +++ b/src/cli/radarr/list_command_handler_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_list_command_from() { @@ -29,6 +30,7 @@ mod tests { "blocklist", "collections", "downloads", + "disk-space", "indexers", "movies", "quality-profiles", @@ -80,8 +82,8 @@ mod tests { assert!(result.is_ok()); - if let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command { - assert_eq!(refresh_command, expected_args); + if let Some(Command::Radarr(RadarrCommand::List(credits_command))) = result.unwrap().command { + assert_eq!(credits_command, expected_args); } } @@ -121,6 +123,7 @@ mod tests { #[case(RadarrListCommand::Blocklist, RadarrEvent::GetBlocklist)] #[case(RadarrListCommand::Collections, RadarrEvent::GetCollections)] #[case(RadarrListCommand::Downloads, RadarrEvent::GetDownloads)] + #[case(RadarrListCommand::DiskSpace, RadarrEvent::GetDiskSpace)] #[case(RadarrListCommand::Indexers, RadarrEvent::GetIndexers)] #[case(RadarrListCommand::Movies, RadarrEvent::GetMovies)] #[case(RadarrListCommand::QualityProfiles, RadarrEvent::GetQualityProfiles)] @@ -130,7 +133,7 @@ mod tests { #[case(RadarrListCommand::Tasks, RadarrEvent::GetTasks)] #[case(RadarrListCommand::Updates, RadarrEvent::GetUpdates)] #[tokio::test] - async fn test_handle_list_blocklist_command( + async fn test_handle_list_command( #[case] list_command: RadarrListCommand, #[case] expected_radarr_event: RadarrEvent, ) { diff --git a/src/cli/radarr/mod.rs b/src/cli/radarr/mod.rs index 9bc654c..6789380 100644 --- a/src/cli/radarr/mod.rs +++ b/src/cli/radarr/mod.rs @@ -12,8 +12,7 @@ use tokio::sync::Mutex; use crate::app::App; use crate::cli::CliCommandHandler; -use crate::execute_network_event; -use crate::models::radarr_models::{ReleaseDownloadBody, TaskName}; +use crate::models::radarr_models::{RadarrReleaseDownloadBody, RadarrTaskName}; use crate::network::radarr_network::RadarrEvent; use crate::network::NetworkTrait; use anyhow::Result; @@ -86,7 +85,7 @@ pub enum RadarrCommand { ManualSearch { #[arg( long, - help = "The Radarr ID of the movie whose releases you wish to fetch and list", + help = "The Radarr ID of the movie whose releases you wish to fetch", required = true )] movie_id: i64, @@ -108,7 +107,7 @@ pub enum RadarrCommand { value_enum, required = true )] - task_name: TaskName, + task_name: RadarrTaskName, }, #[command( about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" @@ -117,7 +116,7 @@ pub enum RadarrCommand { #[arg(long, help = "The ID of the indexer to test", required = true)] indexer_id: i64, }, - #[command(about = "Test all indexers")] + #[command(about = "Test all Radarr indexers")] TestAllIndexers, #[command(about = "Trigger an automatic search for the movie with the specified ID")] TriggerAutomaticSearch { @@ -155,8 +154,8 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrCommand::Add(add_command) => { RadarrAddCommandHandler::with(self.app, add_command, self.network) .handle() @@ -192,41 +191,74 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, ' .network .handle_network_event(RadarrEvent::GetBlocklist.into()) .await?; - execute_network_event!(self, RadarrEvent::ClearBlocklist); + let resp = self + .network + .handle_network_event(RadarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::DownloadRelease { guid, indexer_id, movie_id, } => { - let params = ReleaseDownloadBody { + let params = RadarrReleaseDownloadBody { guid, indexer_id, movie_id, }; - execute_network_event!(self, RadarrEvent::DownloadRelease(Some(params))); + let resp = self + .network + .handle_network_event(RadarrEvent::DownloadRelease(Some(params)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::ManualSearch { movie_id } => { println!("Searching for releases. This may take a minute..."); - execute_network_event!(self, RadarrEvent::GetReleases(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::GetReleases(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::SearchNewMovie { query } => { - execute_network_event!(self, RadarrEvent::SearchNewMovie(Some(query))); + let resp = self + .network + .handle_network_event(RadarrEvent::SearchNewMovie(Some(query)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::StartTask { task_name } => { - execute_network_event!(self, RadarrEvent::StartTask(Some(task_name))); + let resp = self + .network + .handle_network_event(RadarrEvent::StartTask(Some(task_name)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TestIndexer { indexer_id } => { - execute_network_event!(self, RadarrEvent::TestIndexer(Some(indexer_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::TestIndexer(Some(indexer_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TestAllIndexers => { - execute_network_event!(self, RadarrEvent::TestAllIndexers); + println!("Testing all Radarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(RadarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrCommand::TriggerAutomaticSearch { movie_id } => { - execute_network_event!(self, RadarrEvent::TriggerAutomaticSearch(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::TriggerAutomaticSearch(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/radarr_command_tests.rs b/src/cli/radarr/radarr_command_tests.rs index 88bf862..5fd14a1 100644 --- a/src/cli/radarr/radarr_command_tests.rs +++ b/src/cli/radarr/radarr_command_tests.rs @@ -31,7 +31,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_download_release_requires_movie_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -50,7 +50,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_release_requires_guid() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -69,7 +69,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_download_release_requires_indexer_id() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -105,7 +105,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_manual_search_requires_movie_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "manual-search"]); @@ -129,7 +129,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_search_new_movie_requires_query() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "search-new-movie"]); @@ -153,7 +153,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_start_task_requires_task_name() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "start-task"]); @@ -164,7 +164,7 @@ mod tests { ); } - #[rstest] + #[test] fn test_start_task_task_name_validation() { let result = Cli::command().try_get_matches_from([ "managarr", @@ -191,7 +191,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_test_indexer_requires_indexer_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "test-indexer"]); @@ -215,7 +215,7 @@ mod tests { assert!(result.is_ok()); } - #[rstest] + #[test] fn test_trigger_automatic_search_requires_movie_id() { let result = Cli::command().try_get_matches_from(["managarr", "radarr", "trigger-automatic-search"]); @@ -261,8 +261,8 @@ mod tests { }, models::{ radarr_models::{ - BlocklistItem, BlocklistResponse, IndexerSettings, RadarrSerdeable, ReleaseDownloadBody, - TaskName, + BlocklistItem, BlocklistResponse, IndexerSettings, RadarrReleaseDownloadBody, + RadarrSerdeable, RadarrTaskName, }, Serdeable, }, @@ -304,7 +304,7 @@ mod tests { #[tokio::test] async fn test_download_release_command() { - let expected_release_download_body = ReleaseDownloadBody { + let expected_release_download_body = RadarrReleaseDownloadBody { guid: "guid".to_owned(), indexer_id: 1, movie_id: 1, @@ -389,7 +389,7 @@ mod tests { #[tokio::test] async fn test_start_task_command() { - let expected_task_name = TaskName::ApplicationCheckUpdate; + let expected_task_name = RadarrTaskName::ApplicationCheckUpdate; let mut mock_network = MockNetworkTrait::new(); mock_network .expect_handle_network_event() @@ -404,7 +404,7 @@ mod tests { }); let app_arc = Arc::new(Mutex::new(App::default())); let start_task_command = RadarrCommand::StartTask { - task_name: TaskName::ApplicationCheckUpdate, + task_name: RadarrTaskName::ApplicationCheckUpdate, }; let result = RadarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) diff --git a/src/cli/radarr/refresh_command_handler.rs b/src/cli/radarr/refresh_command_handler.rs index 5bb0e73..f329249 100644 --- a/src/cli/radarr/refresh_command_handler.rs +++ b/src/cli/radarr/refresh_command_handler.rs @@ -7,7 +7,6 @@ use tokio::sync::Mutex; use crate::{ app::App, cli::{CliCommandHandler, Command}, - execute_network_event, network::{radarr_network::RadarrEvent, NetworkTrait}, }; @@ -19,7 +18,7 @@ mod refresh_command_handler_tests; #[derive(Debug, Clone, PartialEq, Eq, Subcommand)] pub enum RadarrRefreshCommand { - #[command(about = "Refresh all movie data for all movies in your library")] + #[command(about = "Refresh all movie data for all movies in your Radarr library")] AllMovies, #[command(about = "Refresh movie data and scan disk for the movie with the given ID")] Movie { @@ -63,22 +62,38 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrRefreshCommand> } } - async fn handle(self) -> Result<()> { - match self.command { + async fn handle(self) -> Result { + let result = match self.command { RadarrRefreshCommand::AllMovies => { - execute_network_event!(self, RadarrEvent::UpdateAllMovies); + let resp = self + .network + .handle_network_event(RadarrEvent::UpdateAllMovies.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Collections => { - execute_network_event!(self, RadarrEvent::UpdateCollections); + let resp = self + .network + .handle_network_event(RadarrEvent::UpdateCollections.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Downloads => { - execute_network_event!(self, RadarrEvent::UpdateDownloads); + let resp = self + .network + .handle_network_event(RadarrEvent::UpdateDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? } RadarrRefreshCommand::Movie { movie_id } => { - execute_network_event!(self, RadarrEvent::UpdateAndScan(Some(movie_id))); + let resp = self + .network + .handle_network_event(RadarrEvent::UpdateAndScan(Some(movie_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? } - } + }; - Ok(()) + Ok(result) } } diff --git a/src/cli/radarr/refresh_command_handler_tests.rs b/src/cli/radarr/refresh_command_handler_tests.rs index 2f8352a..3c43830 100644 --- a/src/cli/radarr/refresh_command_handler_tests.rs +++ b/src/cli/radarr/refresh_command_handler_tests.rs @@ -7,6 +7,7 @@ mod tests { use crate::cli::radarr::RadarrCommand; use crate::cli::Command; use crate::Cli; + use pretty_assertions::assert_eq; #[test] fn test_radarr_refresh_command_from() { @@ -81,7 +82,7 @@ mod tests { #[case(RadarrRefreshCommand::Collections, RadarrEvent::UpdateCollections)] #[case(RadarrRefreshCommand::Downloads, RadarrEvent::UpdateDownloads)] #[tokio::test] - async fn test_handle_list_blocklist_command( + async fn test_handle_refresh_command( #[case] refresh_command: RadarrRefreshCommand, #[case] expected_radarr_event: RadarrEvent, ) { diff --git a/src/cli/sonarr/add_command_handler.rs b/src/cli/sonarr/add_command_handler.rs new file mode 100644 index 0000000..c842d0d --- /dev/null +++ b/src/cli/sonarr/add_command_handler.rs @@ -0,0 +1,172 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, Subcommand}; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::sonarr_models::{AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "add_command_handler_tests.rs"] +mod add_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrAddCommand { + #[command(about = "Add a new series to your Sonarr library")] + Series { + #[arg( + long, + help = "The TVDB ID of the series you wish to add to your library", + required = true + )] + tvdb_id: i64, + #[arg(long, help = "The title of the series", required = true)] + title: String, + #[arg( + long, + help = "The root folder path where all series data and metadata should live", + required = true + )] + root_folder_path: String, + #[arg( + long, + help = "The ID of the quality profile to use for this series", + required = true + )] + quality_profile_id: i64, + #[arg( + long, + help = "The ID of the language profile to use for this series", + required = true + )] + language_profile_id: i64, + #[arg( + long, + help = "The type of series", + value_enum, + default_value_t = SeriesType::default() + )] + series_type: SeriesType, + #[arg(long, help = "Disable monitoring for this series")] + disable_monitoring: bool, + #[arg(long, help = "Don't use season folders for this series")] + disable_season_folders: bool, + #[arg( + long, + help = "Tag IDs to tag the series with", + value_parser, + action = ArgAction::Append + )] + tag: Vec, + #[arg( + long, + help = "What Sonarr should monitor", + value_enum, + default_value_t = SeriesMonitor::default() + )] + monitor: SeriesMonitor, + #[arg( + long, + help = "Tell Sonarr to not start a search for this series once it's added to your library" + )] + no_search_for_series: bool, + }, + #[command(about = "Add a new root folder")] + RootFolder { + #[arg(long, help = "The path of the new root folder", required = true)] + root_folder_path: String, + }, + #[command(about = "Add new tag")] + Tag { + #[arg(long, help = "The name of the tag to be added", required = true)] + name: String, + }, +} + +impl From for Command { + fn from(value: SonarrAddCommand) -> Self { + Command::Sonarr(SonarrCommand::Add(value)) + } +} + +pub(super) struct SonarrAddCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrAddCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrAddCommand> for SonarrAddCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrAddCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrAddCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrAddCommand::Series { + tvdb_id, + title, + root_folder_path, + quality_profile_id, + language_profile_id, + series_type, + disable_monitoring, + disable_season_folders, + tag: tags, + monitor, + no_search_for_series, + } => { + let body = AddSeriesBody { + tvdb_id, + title, + monitored: !disable_monitoring, + root_folder_path, + quality_profile_id, + language_profile_id, + series_type: series_type.to_string(), + season_folder: !disable_season_folders, + tags, + add_options: AddSeriesOptions { + monitor: monitor.to_string(), + search_for_cutoff_unmet_episodes: !no_search_for_series, + search_for_missing_episodes: !no_search_for_series, + }, + }; + let resp = self + .network + .handle_network_event(SonarrEvent::AddSeries(Some(body)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrAddCommand::RootFolder { root_folder_path } => { + let resp = self + .network + .handle_network_event(SonarrEvent::AddRootFolder(Some(root_folder_path)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrAddCommand::Tag { name } => { + let resp = self + .network + .handle_network_event(SonarrEvent::AddTag(name).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + Ok(result) + } +} diff --git a/src/cli/sonarr/add_command_handler_tests.rs b/src/cli/sonarr/add_command_handler_tests.rs new file mode 100644 index 0000000..8018b9e --- /dev/null +++ b/src/cli/sonarr/add_command_handler_tests.rs @@ -0,0 +1,582 @@ +#[cfg(test)] +mod tests { + use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; + + use crate::{ + cli::{ + sonarr::{add_command_handler::SonarrAddCommand, SonarrCommand}, + Command, + }, + Cli, + }; + + #[test] + fn test_sonarr_add_command_from() { + let command = SonarrAddCommand::Tag { + name: String::new(), + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Add(command))); + } + + mod cli { + use crate::models::sonarr_models::{SeriesMonitor, SeriesType}; + + use super::*; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_add_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "root-folder"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_root_folder_success() { + let expected_args = SonarrAddCommand::RootFolder { + root_folder_path: "/nfs/test".to_owned(), + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "root-folder", + "--root-folder-path", + "/nfs/test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_tvdb_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--title", + "test", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_title() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_root_folder_path() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--title", + "test", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_quality_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--language-profile-id", + "1", + "--title", + "test", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_series_requires_language_profile_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--root-folder-path", + "test", + "--quality-profile-id", + "1", + "--title", + "test", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_add_series_assert_argument_flags_require_args( + #[values("--series-type", "--tag", "--monitor")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--tvdb-id", + "1", + "--title", + "test", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_all_arguments_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--title", + "test", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_add_series_series_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + "--title", + "test", + "--series-type", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_monitor_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "--title", + "test", + "1", + "--monitor", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_add_series_defaults() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + title: "test".to_owned(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::default(), + disable_monitoring: false, + disable_season_folders: false, + tag: vec![], + monitor: SeriesMonitor::default(), + no_search_for_series: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--title", + "test", + "--tvdb-id", + "1", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_series_tags_is_repeatable() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + title: "test".to_owned(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::default(), + disable_monitoring: false, + disable_season_folders: false, + tag: vec![1, 2], + monitor: SeriesMonitor::default(), + no_search_for_series: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--tvdb-id", + "1", + "--title", + "test", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_series_all_args_defined() { + let expected_args = SonarrAddCommand::Series { + tvdb_id: 1, + title: "test".to_owned(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::Anime, + disable_monitoring: true, + disable_season_folders: true, + tag: vec![1, 2], + monitor: SeriesMonitor::Future, + no_search_for_series: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "add", + "series", + "--root-folder-path", + "/test", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--series-type", + "anime", + "--disable-monitoring", + "--disable-season-folders", + "--tvdb-id", + "1", + "--title", + "test", + "--tag", + "1", + "--tag", + "2", + "--monitor", + "future", + "--no-search-for-series", + ]); + + assert!(result.is_ok()); + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + + #[test] + fn test_add_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "add", "tag"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_add_tag_success() { + let expected_args = SonarrAddCommand::Tag { + name: "test".to_owned(), + }; + + let result = Cli::try_parse_from(["managarr", "sonarr", "add", "tag", "--name", "test"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Add(add_command))) = result.unwrap().command { + assert_eq!(add_command, expected_args); + } + } + } + + mod handler { + use std::sync::Arc; + + use crate::{ + app::App, + cli::{sonarr::add_command_handler::SonarrAddCommandHandler, CliCommandHandler}, + models::{ + sonarr_models::{ + AddSeriesBody, AddSeriesOptions, SeriesMonitor, SeriesType, SonarrSerdeable, + }, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + use super::*; + use mockall::predicate::eq; + + use serde_json::json; + use tokio::sync::Mutex; + + #[tokio::test] + async fn test_handle_add_root_folder_command() { + let expected_root_folder_path = "/nfs/test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddRootFolder(Some(expected_root_folder_path.clone())).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_root_folder_command = SonarrAddCommand::RootFolder { + root_folder_path: expected_root_folder_path, + }; + + let result = + SonarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_add_series_command() { + let expected_add_series_body = AddSeriesBody { + tvdb_id: 1, + title: "test".to_owned(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: "anime".to_owned(), + monitored: false, + tags: vec![1, 2], + season_folder: false, + add_options: AddSeriesOptions { + monitor: "future".to_owned(), + search_for_cutoff_unmet_episodes: false, + search_for_missing_episodes: false, + }, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddSeries(Some(expected_add_series_body)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_series_command = SonarrAddCommand::Series { + tvdb_id: 1, + title: "test".to_owned(), + root_folder_path: "/test".to_owned(), + quality_profile_id: 1, + language_profile_id: 1, + series_type: SeriesType::Anime, + disable_monitoring: true, + disable_season_folders: true, + tag: vec![1, 2], + monitor: SeriesMonitor::Future, + no_search_for_series: true, + }; + + let result = SonarrAddCommandHandler::with(&app_arc, add_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_add_tag_command() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_tag_command = SonarrAddCommand::Tag { + name: expected_tag_name, + }; + + let result = SonarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/delete_command_handler.rs b/src/cli/sonarr/delete_command_handler.rs new file mode 100644 index 0000000..1bf03b2 --- /dev/null +++ b/src/cli/sonarr/delete_command_handler.rs @@ -0,0 +1,156 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::sonarr_models::DeleteSeriesParams, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "delete_command_handler_tests.rs"] +mod delete_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrDeleteCommand { + #[command(about = "Delete the specified item from the Sonarr blocklist")] + BlocklistItem { + #[arg( + long, + help = "The ID of the blocklist item to remove from the blocklist", + required = true + )] + blocklist_item_id: i64, + }, + #[command(about = "Delete the specified download")] + Download { + #[arg(long, help = "The ID of the download to delete", required = true)] + download_id: i64, + }, + #[command(about = "Delete the specified episode file from disk")] + EpisodeFile { + #[arg(long, help = "The ID of the episode file to delete", required = true)] + episode_file_id: i64, + }, + #[command(about = "Delete the indexer with the given ID")] + Indexer { + #[arg(long, help = "The ID of the indexer to delete", required = true)] + indexer_id: i64, + }, + #[command(about = "Delete the root folder with the given ID")] + RootFolder { + #[arg(long, help = "The ID of the root folder to delete", required = true)] + root_folder_id: i64, + }, + #[command(about = "Delete a series from your Sonarr library")] + Series { + #[arg(long, help = "The ID of the series to delete", required = true)] + series_id: i64, + #[arg(long, help = "Delete the series files from disk as well")] + delete_files_from_disk: bool, + #[arg(long, help = "Add a list exclusion for this series")] + add_list_exclusion: bool, + }, + #[command(about = "Delete the tag with the specified ID")] + Tag { + #[arg(long, help = "The ID of the tag to delete", required = true)] + tag_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrDeleteCommand) -> Self { + Command::Sonarr(SonarrCommand::Delete(value)) + } +} + +pub(super) struct SonarrDeleteCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrDeleteCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDeleteCommand> for SonarrDeleteCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrDeleteCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrDeleteCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let resp = match self.command { + SonarrDeleteCommand::BlocklistItem { blocklist_item_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteBlocklistItem(Some(blocklist_item_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::Download { download_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteDownload(Some(download_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::EpisodeFile { episode_file_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteEpisodeFile(Some(episode_file_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::Indexer { indexer_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteIndexer(Some(indexer_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::RootFolder { root_folder_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteRootFolder(Some(root_folder_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::Series { + series_id, + delete_files_from_disk, + add_list_exclusion, + } => { + let delete_series_params = DeleteSeriesParams { + id: series_id, + delete_series_files: delete_files_from_disk, + add_list_exclusion, + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteSeries(Some(delete_series_params)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDeleteCommand::Tag { tag_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::DeleteTag(tag_id).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(resp) + } +} diff --git a/src/cli/sonarr/delete_command_handler_tests.rs b/src/cli/sonarr/delete_command_handler_tests.rs new file mode 100644 index 0000000..9813e3a --- /dev/null +++ b/src/cli/sonarr/delete_command_handler_tests.rs @@ -0,0 +1,466 @@ +#[cfg(test)] +mod tests { + use crate::{ + cli::{ + sonarr::{delete_command_handler::SonarrDeleteCommand, SonarrCommand}, + Command, + }, + Cli, + }; + use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_delete_command_from() { + let command = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Delete(command))); + } + + mod cli { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_delete_blocklist_item_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "blocklist-item"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_blocklist_item_success() { + let expected_args = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "blocklist-item", + "--blocklist-item-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_download_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "download"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_download_success() { + let expected_args = SonarrDeleteCommand::Download { download_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "download", + "--download-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_episode_file_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "episode-file"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_episode_file_success() { + let expected_args = SonarrDeleteCommand::EpisodeFile { episode_file_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "episode-file", + "--episode-file-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_indexer_success() { + let expected_args = SonarrDeleteCommand::Indexer { indexer_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "indexer", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_root_folder_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "root-folder"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_root_folder_success() { + let expected_args = SonarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "root-folder", + "--root-folder-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_series_defaults() { + let expected_args = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: false, + add_list_exclusion: false, + }; + + let result = + Cli::try_parse_from(["managarr", "sonarr", "delete", "series", "--series-id", "1"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_series_all_args_defined() { + let expected_args = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "delete", + "series", + "--series-id", + "1", + "--delete-files-from-disk", + "--add-list-exclusion", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + + #[test] + fn test_delete_tag_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "delete", "tag"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_delete_tag_success() { + let expected_args = SonarrDeleteCommand::Tag { tag_id: 1 }; + + let result = Cli::try_parse_from(["managarr", "sonarr", "delete", "tag", "--tag-id", "1"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Delete(delete_command))) = result.unwrap().command + { + assert_eq!(delete_command, expected_args); + } + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}, + CliCommandHandler, + }, + models::{ + sonarr_models::{DeleteSeriesParams, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_delete_blocklist_item_command() { + let expected_blocklist_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_blocklist_item_command = SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }; + + let result = SonarrDeleteCommandHandler::with( + &app_arc, + delete_blocklist_item_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_delete_download_command() { + let expected_download_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteDownload(Some(expected_download_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_download_command = SonarrDeleteCommand::Download { download_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_delete_indexer_command() { + let expected_indexer_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteIndexer(Some(expected_indexer_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_indexer_command = SonarrDeleteCommand::Indexer { indexer_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_delete_root_folder_command() { + let expected_root_folder_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteRootFolder(Some(expected_root_folder_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_root_folder_command = SonarrDeleteCommand::RootFolder { root_folder_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_delete_series_command() { + let expected_delete_series_params = DeleteSeriesParams { + id: 1, + delete_series_files: true, + add_list_exclusion: true, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteSeries(Some(expected_delete_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_series_command = SonarrDeleteCommand::Series { + series_id: 1, + delete_files_from_disk: true, + add_list_exclusion: true, + }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_delete_tag_command() { + let expected_tag_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteTag(expected_tag_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_tag_command = SonarrDeleteCommand::Tag { tag_id: 1 }; + + let result = + SonarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/download_command_handler.rs b/src/cli/sonarr/download_command_handler.rs new file mode 100644 index 0000000..f990ab2 --- /dev/null +++ b/src/cli/sonarr/download_command_handler.rs @@ -0,0 +1,169 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + models::sonarr_models::SonarrReleaseDownloadBody, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "download_command_handler_tests.rs"] +mod download_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrDownloadCommand { + #[command(about = "Manually download the given series release for the specified series ID")] + Series { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The series ID that the release is associated with", + required = true + )] + series_id: i64, + }, + #[command( + about = "Manually download the given season release corresponding to the series specified with the series ID" + )] + Season { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The series ID that the release is associated with", + required = true + )] + series_id: i64, + #[arg( + long, + help = "The season number that the release corresponds to", + required = true + )] + season_number: i64, + }, + #[command(about = "Manually download the given episode release for the specified episode ID")] + Episode { + #[arg(long, help = "The GUID of the release to download", required = true)] + guid: String, + #[arg( + long, + help = "The indexer ID to download the release from", + required = true + )] + indexer_id: i64, + #[arg( + long, + help = "The episode ID that the release is associated with", + required = true + )] + episode_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrDownloadCommand) -> Self { + Command::Sonarr(SonarrCommand::Download(value)) + } +} + +pub(super) struct SonarrDownloadCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrDownloadCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrDownloadCommand> + for SonarrDownloadCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrDownloadCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrDownloadCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrDownloadCommand::Series { + guid, + indexer_id, + series_id, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + series_id: Some(series_id), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDownloadCommand::Season { + guid, + indexer_id, + series_id, + season_number, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + series_id: Some(series_id), + season_number: Some(season_number), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrDownloadCommand::Episode { + guid, + indexer_id, + episode_id, + } => { + let params = SonarrReleaseDownloadBody { + guid, + indexer_id, + episode_id: Some(episode_id), + ..SonarrReleaseDownloadBody::default() + }; + let resp = self + .network + .handle_network_event(SonarrEvent::DownloadRelease(params).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/download_command_handler_tests.rs b/src/cli/sonarr/download_command_handler_tests.rs new file mode 100644 index 0000000..c1a617b --- /dev/null +++ b/src/cli/sonarr/download_command_handler_tests.rs @@ -0,0 +1,423 @@ +#[cfg(test)] +mod tests { + use crate::{ + cli::{ + sonarr::{download_command_handler::SonarrDownloadCommand, SonarrCommand}, + Command, + }, + Cli, + }; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_download_command_from() { + let command = SonarrDownloadCommand::Series { + guid: "Test".to_owned(), + indexer_id: 1, + series_id: 1, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Download(command))); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_download_series_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--indexer-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_series_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--indexer-id", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_series_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--guid", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_series_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "series", + "--guid", + "1", + "--series-id", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_download_season_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--season-number", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--series-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--indexer-id", + "1", + "--season-number", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--guid", + "1", + "--season-number", + "1", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_season_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "season", + "--guid", + "1", + "--series-id", + "1", + "--season-number", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_download_episode_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--indexer-id", + "1", + "--guid", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requires_guid() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--indexer-id", + "1", + "--episode-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requires_indexer_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--guid", + "1", + "--episode-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_download_episode_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "download", + "episode", + "--guid", + "1", + "--episode-id", + "1", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}, + CliCommandHandler, + }, + models::{ + sonarr_models::{SonarrReleaseDownloadBody, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_download_series_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Series { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_download_season_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: Some(1), + season_number: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Season { + guid: "guid".to_owned(), + indexer_id: 1, + series_id: 1, + season_number: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_download_episode_release_command() { + let expected_release_download_body = SonarrReleaseDownloadBody { + guid: "guid".to_owned(), + indexer_id: 1, + episode_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_release_download_body).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_release_command = SonarrDownloadCommand::Episode { + guid: "guid".to_owned(), + indexer_id: 1, + episode_id: 1, + }; + + let result = + SonarrDownloadCommandHandler::with(&app_arc, download_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/edit_command_handler.rs b/src/cli/sonarr/edit_command_handler.rs new file mode 100644 index 0000000..bd879c3 --- /dev/null +++ b/src/cli/sonarr/edit_command_handler.rs @@ -0,0 +1,363 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::{ArgAction, ArgGroup, Subcommand}; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{mutex_flags_or_option, CliCommandHandler, Command}, + models::{ + servarr_models::EditIndexerParams, + sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "edit_command_handler_tests.rs"] +mod edit_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrEditCommand { + #[command( + about = "Edit and indexer settings that apply to all indexers", + group( + ArgGroup::new("edit_settings") + .args([ + "maximum_size", + "minimum_age", + "retention", + "rss_sync_interval", + ]).required(true) + .multiple(true)) + )] + AllIndexerSettings { + #[arg( + long, + help = "The maximum size for a release to be grabbed in MB. Set to zero to set to unlimited" + )] + maximum_size: Option, + #[arg( + long, + help = "Usenet only: Minimum age in minutes of NZBs before they are grabbed. Use this to give new releases time to propagate to your usenet provider." + )] + minimum_age: Option, + #[arg( + long, + help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention" + )] + retention: Option, + #[arg( + long, + help = "The RSS sync interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)" + )] + rss_sync_interval: Option, + }, + #[command( + about = "Edit preferences for the specified indexer", + group( + ArgGroup::new("edit_indexer") + .args([ + "name", + "enable_rss", + "disable_rss", + "enable_automatic_search", + "disable_automatic_search", + "enable_interactive_search", + "disable_automatic_search", + "url", + "api_key", + "seed_ratio", + "tag", + "priority", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Indexer { + #[arg( + long, + help = "The ID of the indexer whose settings you wish to edit", + required = true + )] + indexer_id: i64, + #[arg(long, help = "The name of the indexer")] + name: Option, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when Sonarr periodically looks for releases via RSS Sync", + conflicts_with = "disable_rss" + )] + enable_rss: bool, + #[arg( + long, + help = "Disable using this indexer when Sonarr periodically looks for releases via RSS Sync", + conflicts_with = "enable_rss" + )] + disable_rss: bool, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when automatic searches are performed via the UI or by Sonarr", + conflicts_with = "disable_automatic_search" + )] + enable_automatic_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever automatic searches are performed via the UI or by Sonarr", + conflicts_with = "enable_automatic_search" + )] + disable_automatic_search: bool, + #[arg( + long, + help = "Indicate to Sonarr that this indexer should be used when an interactive search is used", + conflicts_with = "disable_interactive_search" + )] + enable_interactive_search: bool, + #[arg( + long, + help = "Disable using this indexer whenever an interactive search is performed", + conflicts_with = "enable_interactive_search" + )] + disable_interactive_search: bool, + #[arg(long, help = "The URL of the indexer")] + url: Option, + #[arg(long, help = "The API key used to access the indexer's API")] + api_key: Option, + #[arg( + long, + help = "The ratio a torrent should reach before stopping; Empty uses the download client's default. Ratio should be at least 1.0 and follow the indexer's rules" + )] + seed_ratio: Option, + #[arg( + long, + help = "Only use this indexer for series with at least one matching tag ID. Leave blank to use with all series.", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg( + long, + help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Sonarr will still use all enabled indexers for RSS Sync and Searching" + )] + priority: Option, + #[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")] + clear_tags: bool, + }, + #[command( + about = "Edit preferences for the specified series", + group( + ArgGroup::new("edit_series") + .args([ + "enable_monitoring", + "disable_monitoring", + "enable_season_folders", + "disable_season_folders", + "series_type", + "quality_profile_id", + "language_profile_id", + "root_folder_path", + "tag", + "clear_tags" + ]).required(true) + .multiple(true)) + )] + Series { + #[arg( + long, + help = "The ID of the series whose settings you want to edit", + required = true + )] + series_id: i64, + #[arg( + long, + help = "Enable monitoring of this series in Sonarr so Sonarr will automatically download this series if it is available", + conflicts_with = "disable_monitoring" + )] + enable_monitoring: bool, + #[arg( + long, + help = "Disable monitoring of this series so Sonarr does not automatically download the series if it is found to be available", + conflicts_with = "enable_monitoring" + )] + disable_monitoring: bool, + #[arg( + long, + help = "The minimum availability to monitor for this film", + value_enum + )] + #[arg( + long, + help = "Enable sorting episodes of this series into season folders", + conflicts_with = "disable_season_folders" + )] + enable_season_folders: bool, + #[arg( + long, + help = "Disable sorting episodes of this series into season folders", + conflicts_with = "enable_season_folders" + )] + disable_season_folders: bool, + #[arg(long, help = "The type of series", value_enum)] + series_type: Option, + #[arg(long, help = "The ID of the quality profile to use for this series")] + quality_profile_id: Option, + #[arg(long, help = "The ID of the language profile to use for this series")] + language_profile_id: Option, + #[arg( + long, + help = "The root folder path where all film data and metadata should live" + )] + root_folder_path: Option, + #[arg( + long, + help = "Tag IDs to tag this series with", + value_parser, + action = ArgAction::Append, + conflicts_with = "clear_tags" + )] + tag: Option>, + #[arg(long, help = "Clear all tags on this series", conflicts_with = "tag")] + clear_tags: bool, + }, +} + +impl From for Command { + fn from(value: SonarrEditCommand) -> Self { + Command::Sonarr(SonarrCommand::Edit(value)) + } +} + +pub(super) struct SonarrEditCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrEditCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrEditCommand> for SonarrEditCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrEditCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrEditCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrEditCommand::AllIndexerSettings { + maximum_size, + minimum_age, + retention, + rss_sync_interval, + } => { + if let Serdeable::Sonarr(SonarrSerdeable::IndexerSettings(previous_indexer_settings)) = self + .network + .handle_network_event(SonarrEvent::GetAllIndexerSettings.into()) + .await? + { + let params = IndexerSettings { + id: 1, + maximum_size: maximum_size.unwrap_or(previous_indexer_settings.maximum_size), + minimum_age: minimum_age.unwrap_or(previous_indexer_settings.minimum_age), + retention: retention.unwrap_or(previous_indexer_settings.retention), + rss_sync_interval: rss_sync_interval + .unwrap_or(previous_indexer_settings.rss_sync_interval), + }; + self + .network + .handle_network_event(SonarrEvent::EditAllIndexerSettings(Some(params)).into()) + .await?; + "All indexer settings updated".to_owned() + } else { + String::new() + } + } + SonarrEditCommand::Indexer { + indexer_id, + name, + enable_rss, + disable_rss, + enable_automatic_search, + disable_automatic_search, + enable_interactive_search, + disable_interactive_search, + url, + api_key, + seed_ratio, + tag, + priority, + clear_tags, + } => { + let rss_value = mutex_flags_or_option(enable_rss, disable_rss); + let automatic_search_value = + mutex_flags_or_option(enable_automatic_search, disable_automatic_search); + let interactive_search_value = + mutex_flags_or_option(enable_interactive_search, disable_interactive_search); + let edit_indexer_params = EditIndexerParams { + indexer_id, + name, + enable_rss: rss_value, + enable_automatic_search: automatic_search_value, + enable_interactive_search: interactive_search_value, + url, + api_key, + seed_ratio, + tags: tag, + priority, + clear_tags, + }; + + self + .network + .handle_network_event(SonarrEvent::EditIndexer(Some(edit_indexer_params)).into()) + .await?; + "Indexer updated".to_owned() + } + SonarrEditCommand::Series { + series_id, + enable_monitoring, + disable_monitoring, + enable_season_folders, + disable_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tag, + clear_tags, + } => { + let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring); + let season_folders_value = + mutex_flags_or_option(enable_season_folders, disable_season_folders); + let edit_series_params = EditSeriesParams { + series_id, + monitored: monitored_value, + use_season_folders: season_folders_value, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags: tag, + clear_tags, + }; + + self + .network + .handle_network_event(SonarrEvent::EditSeries(Some(edit_series_params)).into()) + .await?; + "Series Updated".to_owned() + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/edit_command_handler_tests.rs b/src/cli/sonarr/edit_command_handler_tests.rs new file mode 100644 index 0000000..eaef63f --- /dev/null +++ b/src/cli/sonarr/edit_command_handler_tests.rs @@ -0,0 +1,874 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{edit_command_handler::SonarrEditCommand, SonarrCommand}, + Command, + }; + + #[test] + fn test_sonarr_edit_command_from() { + let command = SonarrEditCommand::AllIndexerSettings { + maximum_size: None, + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Edit(command))); + } + + mod cli { + use crate::{models::sonarr_models::SeriesType, Cli}; + + use super::*; + use clap::{error::ErrorKind, CommandFactory, Parser}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[test] + fn test_edit_all_indexer_settings_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "all-indexer-settings"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[rstest] + fn test_edit_all_indexer_settings_assert_argument_flags_require_args( + #[values( + "--maximum-size", + "--minimum-age", + "--retention", + "--rss-sync-interval" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() { + let expected_args = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: None, + retention: None, + rss_sync_interval: None, + }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_all_indexer_settings_all_arguments_defined() { + let expected_args = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "all-indexer-settings", + "--maximum-size", + "1", + "--minimum-age", + "1", + "--retention", + "1", + "--rss-sync-interval", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_indexer_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_with_indexer_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_indexer_rss_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-rss", + "--disable-rss", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_automatic_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-automatic-search", + "--disable-automatic-search", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_interactive_search_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--enable-interactive-search", + "--disable-interactive-search", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_indexer_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_indexer_assert_argument_flags_require_args( + #[values("--name", "--url", "--api-key", "--seed-ratio", "--tag", "--priority")] flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_indexer_only_requires_at_least_one_argument_plus_indexer_id() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: None, + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--name", + "Test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_indexer_tag_argument_is_repeatable() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: None, + enable_rss: false, + disable_rss: false, + enable_automatic_search: false, + disable_automatic_search: false, + enable_interactive_search: false, + disable_interactive_search: false, + url: None, + api_key: None, + seed_ratio: None, + tag: Some(vec![1, 2]), + priority: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_indexer_all_arguments_defined() { + let expected_args = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "indexer", + "--indexer-id", + "1", + "--name", + "Test", + "--enable-rss", + "--enable-automatic-search", + "--enable-interactive-search", + "--url", + "http://test.com", + "--api-key", + "testKey", + "--seed-ratio", + "1.2", + "--tag", + "1", + "--tag", + "2", + "--priority", + "25", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_series_requires_arguments() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "edit", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_series_with_series_id_still_requires_arguments() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_edit_series_monitoring_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-monitoring", + "--disable-monitoring", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_series_season_folders_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-season-folders", + "--disable-season-folders", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[test] + fn test_edit_series_tag_flags_conflict() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--tag", + "1", + "--clear-tags", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); + } + + #[rstest] + fn test_edit_series_assert_argument_flags_require_args( + #[values( + "--series-type", + "--quality-profile-id", + "--language-profile-id", + "--root-folder-path", + "--tag" + )] + flag: &str, + ) { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + flag, + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_series_series_type_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--series-type", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_edit_series_only_requires_at_least_one_argument_plus_series_id() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: None, + quality_profile_id: None, + language_profile_id: None, + root_folder_path: Some("/nfs/test".to_owned()), + tag: None, + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--root-folder-path", + "/nfs/test", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_series_tag_argument_is_repeatable() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: None, + quality_profile_id: None, + language_profile_id: None, + root_folder_path: None, + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + + #[test] + fn test_edit_series_all_arguments_defined() { + let expected_args = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: true, + disable_monitoring: false, + enable_season_folders: true, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "edit", + "series", + "--series-id", + "1", + "--enable-monitoring", + "--enable-season-folders", + "--series-type", + "anime", + "--quality-profile-id", + "1", + "--language-profile-id", + "1", + "--root-folder-path", + "/nfs/test", + "--tag", + "1", + "--tag", + "2", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Edit(edit_command))) = result.unwrap().command { + assert_eq!(edit_command, expected_args); + } + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler}, + CliCommandHandler, + }, + models::{ + servarr_models::EditIndexerParams, + sonarr_models::{EditSeriesParams, IndexerSettings, SeriesType, SonarrSerdeable}, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_command() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 1, + minimum_age: 1, + retention: 1, + rss_sync_interval: 1, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( + IndexerSettings { + id: 1, + maximum_size: 2, + minimum_age: 2, + retention: 2, + rss_sync_interval: 2, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_all_indexer_settings_command = SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }; + + let result = SonarrEditCommandHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_indexer_command() { + let expected_edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: Some(true), + enable_automatic_search: Some(true), + enable_interactive_search: Some(true), + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditIndexer(Some(expected_edit_indexer_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_indexer_command = SonarrEditCommand::Indexer { + indexer_id: 1, + name: Some("Test".to_owned()), + enable_rss: true, + disable_rss: false, + enable_automatic_search: true, + disable_automatic_search: false, + enable_interactive_search: true, + disable_interactive_search: false, + url: Some("http://test.com".to_owned()), + api_key: Some("testKey".to_owned()), + seed_ratio: Some("1.2".to_owned()), + tag: Some(vec![1, 2]), + priority: Some(25), + clear_tags: false, + }; + + let result = + SonarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_series_command() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(true), + use_season_folders: Some(true), + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: true, + disable_monitoring: false, + enable_season_folders: true, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_series_command_handles_disable_monitoring_flag_properly() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: true, + enable_season_folders: false, + disable_season_folders: true, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_edit_series_command_no_monitoring_boolean_flags_returns_none_value() { + let expected_edit_series_params = EditSeriesParams { + series_id: 1, + monitored: None, + use_season_folders: None, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tags: Some(vec![1, 2]), + clear_tags: false, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditSeries(Some(expected_edit_series_params)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_series_command = SonarrEditCommand::Series { + series_id: 1, + enable_monitoring: false, + disable_monitoring: false, + enable_season_folders: false, + disable_season_folders: false, + series_type: Some(SeriesType::Anime), + quality_profile_id: Some(1), + language_profile_id: Some(1), + root_folder_path: Some("/nfs/test".to_owned()), + tag: Some(vec![1, 2]), + clear_tags: false, + }; + + let result = SonarrEditCommandHandler::with(&app_arc, edit_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/get_command_handler.rs b/src/cli/sonarr/get_command_handler.rs new file mode 100644 index 0000000..37e3101 --- /dev/null +++ b/src/cli/sonarr/get_command_handler.rs @@ -0,0 +1,122 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "get_command_handler_tests.rs"] +mod get_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrGetCommand { + #[command(about = "Get the shared settings for all indexers")] + AllIndexerSettings, + #[command(about = "Get detailed information for the episode with the given ID")] + EpisodeDetails { + #[arg( + long, + help = "The Sonarr ID of the episode whose details you wish to fetch", + required = true + )] + episode_id: i64, + }, + #[command(about = "Fetch the host config for your Sonarr instance")] + HostConfig, + #[command(about = "Fetch the security config for your Sonarr instance")] + SecurityConfig, + #[command(about = "Get detailed information for the series with the given ID")] + SeriesDetails { + #[arg( + long, + help = "The Sonarr ID of the series whose details you wish to fetch", + required = true + )] + series_id: i64, + }, + #[command(about = "Get the system status")] + SystemStatus, +} + +impl From for Command { + fn from(value: SonarrGetCommand) -> Self { + Command::Sonarr(SonarrCommand::Get(value)) + } +} + +pub(super) struct SonarrGetCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrGetCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrGetCommand> for SonarrGetCommandHandler<'a, 'b> { + fn with( + _app: &'a Arc>>, + command: SonarrGetCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrGetCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrGetCommand::AllIndexerSettings => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetAllIndexerSettings.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrGetCommand::EpisodeDetails { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeDetails(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrGetCommand::HostConfig => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetHostConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrGetCommand::SecurityConfig => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetSecurityConfig.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrGetCommand::SeriesDetails { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetSeriesDetails(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrGetCommand::SystemStatus => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetStatus.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/get_command_handler_tests.rs b/src/cli/sonarr/get_command_handler_tests.rs new file mode 100644 index 0000000..12a6225 --- /dev/null +++ b/src/cli/sonarr/get_command_handler_tests.rs @@ -0,0 +1,277 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{get_command_handler::SonarrGetCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_get_command_from() { + let command = SonarrGetCommand::SystemStatus; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Get(command))); + } + + mod cli { + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn test_all_indexer_settings_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "all-indexer-settings"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_system_status_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "system-status"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_episode_details_requires_episode_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "episode-details"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_episode_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "get", + "episode-details", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_get_host_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "host-config"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_get_security_config_has_no_arg_requirements() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "security-config"]); + + assert!(result.is_ok()); + } + + #[test] + fn test_series_details_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "get", "series-details"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_series_details_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "get", + "series-details", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_get_all_indexer_settings_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_all_indexer_settings_command = SonarrGetCommand::AllIndexerSettings; + + let result = SonarrGetCommandHandler::with( + &app_arc, + get_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_episode_details_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeDetails(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_episode_details_command = SonarrGetCommand::EpisodeDetails { episode_id: 1 }; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_episode_details_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_host_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetHostConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_host_config_command = SonarrGetCommand::HostConfig; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_security_config_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetSecurityConfig.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_security_config_command = SonarrGetCommand::SecurityConfig; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_series_details_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeriesDetails(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_series_details_command = SonarrGetCommand::SeriesDetails { series_id: 1 }; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_series_details_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_get_system_status_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_system_status_command = SonarrGetCommand::SystemStatus; + + let result = + SonarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/list_command_handler.rs b/src/cli/sonarr/list_command_handler.rs new file mode 100644 index 0000000..cf92a36 --- /dev/null +++ b/src/cli/sonarr/list_command_handler.rs @@ -0,0 +1,249 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "list_command_handler_tests.rs"] +mod list_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrListCommand { + #[command(about = "List all items in the Sonarr blocklist")] + Blocklist, + #[command(about = "List all active downloads in Sonarr")] + Downloads, + #[command(about = "List disk space details for all provisioned root folders in Sonarr")] + DiskSpace, + #[command(about = "List the episodes for the series with the given ID")] + Episodes { + #[arg( + long, + help = "The Sonarr ID of the series whose episodes you wish to fetch", + required = true + )] + series_id: i64, + }, + #[command(about = "Fetch all history events for the episode with the given ID")] + EpisodeHistory { + #[arg( + long, + help = "The Sonarr ID of the episode whose history you wish to fetch", + required = true + )] + episode_id: i64, + }, + #[command(about = "Fetch all Sonarr history events")] + History { + #[arg(long, help = "How many history events to fetch", default_value_t = 500)] + events: u64, + }, + #[command(about = "List all Sonarr indexers")] + Indexers, + #[command(about = "List all Sonarr language profiles")] + LanguageProfiles, + #[command(about = "Fetch Sonarr logs")] + Logs { + #[arg(long, help = "How many log events to fetch", default_value_t = 500)] + events: u64, + #[arg( + long, + help = "Output the logs in the same format as they appear in the log files" + )] + output_in_log_format: bool, + }, + #[command(about = "List all Sonarr quality profiles")] + QualityProfiles, + #[command(about = "List all queued events")] + QueuedEvents, + #[command(about = "List all root folders in Sonarr")] + RootFolders, + #[command(about = "List all series in your Sonarr library")] + Series, + #[command(about = "Fetch all history events for the series with the given ID")] + SeriesHistory { + #[arg( + long, + help = "The Sonarr ID of the series whose history you wish to fetch", + required = true + )] + series_id: i64, + }, + #[command(about = "List all Sonarr tags")] + Tags, + #[command(about = "List all Sonarr tasks")] + Tasks, + #[command(about = "List all Sonarr updates")] + Updates, +} + +impl From for Command { + fn from(value: SonarrListCommand) -> Self { + Command::Sonarr(SonarrCommand::List(value)) + } +} + +pub(super) struct SonarrListCommandHandler<'a, 'b> { + app: &'a Arc>>, + command: SonarrListCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrListCommand> for SonarrListCommandHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: SonarrListCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrListCommandHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrListCommand::Blocklist => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Downloads => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::DiskSpace => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetDiskSpace.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Episodes { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodes(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::EpisodeHistory { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeHistory(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::History { events: items } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetHistory(Some(items)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Indexers => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::LanguageProfiles => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetLanguageProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Logs { + events, + output_in_log_format, + } => { + let logs = self + .network + .handle_network_event(SonarrEvent::GetLogs(Some(events)).into()) + .await?; + + if output_in_log_format { + let log_lines = self.app.lock().await.data.sonarr_data.logs.items.clone(); + + serde_json::to_string_pretty(&log_lines)? + } else { + serde_json::to_string_pretty(&logs)? + } + } + SonarrListCommand::QualityProfiles => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetQualityProfiles.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::QueuedEvents => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetQueuedEvents.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::RootFolders => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetRootFolders.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Series => { + let resp = self + .network + .handle_network_event(SonarrEvent::ListSeries.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::SeriesHistory { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetSeriesHistory(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Tags => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetTags.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Tasks => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetTasks.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrListCommand::Updates => { + let resp = self + .network + .handle_network_event(SonarrEvent::GetUpdates.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/list_command_handler_tests.rs b/src/cli/sonarr/list_command_handler_tests.rs new file mode 100644 index 0000000..7e71599 --- /dev/null +++ b/src/cli/sonarr/list_command_handler_tests.rs @@ -0,0 +1,372 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{list_command_handler::SonarrListCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_list_command_from() { + let command = SonarrListCommand::Series; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::List(command))); + } + + mod cli { + use super::*; + use clap::{error::ErrorKind, Parser}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_list_commands_have_no_arg_requirements( + #[values( + "blocklist", + "series", + "downloads", + "disk-space", + "quality-profiles", + "indexers", + "queued-events", + "root-folders", + "tags", + "tasks", + "updates", + "language-profiles" + )] + subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", subcommand]); + + assert!(result.is_ok()); + } + + #[test] + fn test_list_episodes_requires_series_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episodes"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_episode_history_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "episode-history"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_episode_history_success() { + let expected_args = SonarrListCommand::EpisodeHistory { episode_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "list", + "episode-history", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(episode_history_command))) = + result.unwrap().command + { + assert_eq!(episode_history_command, expected_args); + } + } + + #[test] + fn test_list_history_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "history", "--events"]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_history_default_values() { + let expected_args = SonarrListCommand::History { events: 500 }; + let result = Cli::try_parse_from(["managarr", "sonarr", "list", "history"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(history_command))) = result.unwrap().command { + assert_eq!(history_command, expected_args); + } + } + + #[test] + fn test_list_logs_events_flag_requires_arguments() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "logs", "--events"]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_list_logs_default_values() { + let expected_args = SonarrListCommand::Logs { + events: 500, + output_in_log_format: false, + }; + let result = Cli::try_parse_from(["managarr", "sonarr", "list", "logs"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(logs_command))) = result.unwrap().command { + assert_eq!(logs_command, expected_args); + } + } + + #[test] + fn test_list_episodes_success() { + let expected_args = SonarrListCommand::Episodes { series_id: 1 }; + let result = + Cli::try_parse_from(["managarr", "sonarr", "list", "episodes", "--series-id", "1"]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(episodes_command))) = result.unwrap().command + { + assert_eq!(episodes_command, expected_args); + } + } + + #[test] + fn test_list_series_history_requires_series_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series-history"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_list_series_history_success() { + let expected_args = SonarrListCommand::SeriesHistory { series_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "list", + "series-history", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::List(series_command))) = result.unwrap().command { + assert_eq!(series_command, expected_args); + } + } + } + + mod handler { + + use std::sync::Arc; + + use mockall::predicate::eq; + use rstest::rstest; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::cli::sonarr::list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; + use crate::cli::CliCommandHandler; + use crate::models::sonarr_models::SonarrSerdeable; + use crate::models::Serdeable; + use crate::network::sonarr_network::SonarrEvent; + use crate::{ + app::App, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[rstest] + #[case(SonarrListCommand::Blocklist, SonarrEvent::GetBlocklist)] + #[case(SonarrListCommand::Downloads, SonarrEvent::GetDownloads)] + #[case(SonarrListCommand::DiskSpace, SonarrEvent::GetDiskSpace)] + #[case(SonarrListCommand::Indexers, SonarrEvent::GetIndexers)] + #[case(SonarrListCommand::QualityProfiles, SonarrEvent::GetQualityProfiles)] + #[case(SonarrListCommand::QueuedEvents, SonarrEvent::GetQueuedEvents)] + #[case(SonarrListCommand::RootFolders, SonarrEvent::GetRootFolders)] + #[case(SonarrListCommand::Series, SonarrEvent::ListSeries)] + #[case(SonarrListCommand::Tags, SonarrEvent::GetTags)] + #[case(SonarrListCommand::Tasks, SonarrEvent::GetTasks)] + #[case(SonarrListCommand::Updates, SonarrEvent::GetUpdates)] + #[case(SonarrListCommand::LanguageProfiles, SonarrEvent::GetLanguageProfiles)] + #[tokio::test] + async fn test_handle_list_command( + #[case] list_command: SonarrListCommand, + #[case] expected_sonarr_event: SonarrEvent, + ) { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(expected_sonarr_event.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + + let result = SonarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_list_episodes_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodes(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_episodes_command = SonarrListCommand::Episodes { series_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_episodes_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_list_history_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetHistory(Some(expected_events)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_history_command = SonarrListCommand::History { events: 1000 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_list_logs_command() { + let expected_events = 1000; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetLogs(Some(expected_events)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_logs_command = SonarrListCommand::Logs { + events: 1000, + output_in_log_format: false, + }; + + let result = SonarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_list_series_history_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeriesHistory(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_series_history_command = SonarrListCommand::SeriesHistory { series_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_series_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_list_episode_history_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeHistory(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_episode_history_command = SonarrListCommand::EpisodeHistory { episode_id: 1 }; + + let result = + SonarrListCommandHandler::with(&app_arc, list_episode_history_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/manual_search_command_handler.rs b/src/cli/sonarr/manual_search_command_handler.rs new file mode 100644 index 0000000..e8e1ca3 --- /dev/null +++ b/src/cli/sonarr/manual_search_command_handler.rs @@ -0,0 +1,99 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "manual_search_command_handler_tests.rs"] +mod manual_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrManualSearchCommand { + #[command(about = "Trigger a manual search of releases for the episode with the given ID")] + Episode { + #[arg( + long, + help = "The Sonarr ID of the episode whose releases you wish to fetch and list", + required = true + )] + episode_id: i64, + }, + #[command( + about = "Trigger a manual search of releases for the given season corresponding to the series with the given ID.\nNote that when downloading a season release, ensure that the release includes 'fullSeason: true', otherwise you'll run into issues" + )] + Season { + #[arg( + long, + help = "The Sonarr ID of the series whose releases you wish to fetch and list", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, +} + +impl From for Command { + fn from(value: SonarrManualSearchCommand) -> Self { + Command::Sonarr(SonarrCommand::ManualSearch(value)) + } +} + +pub(super) struct SonarrManualSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrManualSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrManualSearchCommand> + for SonarrManualSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrManualSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrManualSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrManualSearchCommand::Episode { episode_id } => { + println!("Searching for episode releases. This may take a minute..."); + let resp = self + .network + .handle_network_event(SonarrEvent::GetEpisodeReleases(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrManualSearchCommand::Season { + series_id, + season_number, + } => { + println!("Searching for season releases. This may take a minute..."); + let resp = self + .network + .handle_network_event( + SonarrEvent::GetSeasonReleases(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/manual_search_command_handler_tests.rs b/src/cli/sonarr/manual_search_command_handler_tests.rs new file mode 100644 index 0000000..53b26ad --- /dev/null +++ b/src/cli/sonarr/manual_search_command_handler_tests.rs @@ -0,0 +1,188 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{manual_search_command_handler::SonarrManualSearchCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_manual_search_command_from() { + let command = SonarrManualSearchCommand::Episode { episode_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Sonarr(SonarrCommand::ManualSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_manual_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "season", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_manual_episode_search_requires_episode_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "manual-search", "episode"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_manual_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "manual-search", + "episode", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::manual_search_command_handler::{ + SonarrManualSearchCommand, SonarrManualSearchCommandHandler, + }, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_manual_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = SonarrManualSearchCommand::Episode { episode_id: 1 }; + + let result = SonarrManualSearchCommandHandler::with( + &app_arc, + manual_episode_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_manual_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetSeasonReleases(Some((expected_series_id, expected_season_number))).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_season_search_command = SonarrManualSearchCommand::Season { + series_id: 1, + season_number: 1, + }; + + let result = SonarrManualSearchCommandHandler::with( + &app_arc, + manual_season_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/mod.rs b/src/cli/sonarr/mod.rs new file mode 100644 index 0000000..1cb1e42 --- /dev/null +++ b/src/cli/sonarr/mod.rs @@ -0,0 +1,252 @@ +use std::sync::Arc; + +use add_command_handler::{SonarrAddCommand, SonarrAddCommandHandler}; +use anyhow::Result; +use clap::Subcommand; +use delete_command_handler::{SonarrDeleteCommand, SonarrDeleteCommandHandler}; +use download_command_handler::{SonarrDownloadCommand, SonarrDownloadCommandHandler}; +use edit_command_handler::{SonarrEditCommand, SonarrEditCommandHandler}; +use get_command_handler::{SonarrGetCommand, SonarrGetCommandHandler}; +use list_command_handler::{SonarrListCommand, SonarrListCommandHandler}; +use manual_search_command_handler::{SonarrManualSearchCommand, SonarrManualSearchCommandHandler}; +use refresh_command_handler::{SonarrRefreshCommand, SonarrRefreshCommandHandler}; +use tokio::sync::Mutex; +use trigger_automatic_search_command_handler::{ + SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler, +}; + +use crate::{ + app::App, + models::sonarr_models::SonarrTaskName, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::{CliCommandHandler, Command}; + +mod add_command_handler; +mod delete_command_handler; +mod download_command_handler; +mod edit_command_handler; +mod get_command_handler; +mod list_command_handler; +mod manual_search_command_handler; +mod refresh_command_handler; +mod trigger_automatic_search_command_handler; + +#[cfg(test)] +#[path = "sonarr_command_tests.rs"] +mod sonarr_command_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrCommand { + #[command( + subcommand, + about = "Commands to add or create new resources within your Sonarr instance" + )] + Add(SonarrAddCommand), + #[command( + subcommand, + about = "Commands to delete resources from your Sonarr instance" + )] + Delete(SonarrDeleteCommand), + #[command( + subcommand, + about = "Commands to edit resources in your Sonarr instance" + )] + Edit(SonarrEditCommand), + #[command( + subcommand, + about = "Commands to fetch details of the resources in your Sonarr instance" + )] + Get(SonarrGetCommand), + #[command( + subcommand, + about = "Commands to download releases in your Sonarr instance" + )] + Download(SonarrDownloadCommand), + #[command( + subcommand, + about = "Commands to list attributes from your Sonarr instance" + )] + List(SonarrListCommand), + #[command( + subcommand, + about = "Commands to refresh the data in your Sonarr instance" + )] + Refresh(SonarrRefreshCommand), + #[command(subcommand, about = "Commands to manually search for releases")] + ManualSearch(SonarrManualSearchCommand), + #[command( + subcommand, + about = "Commands to trigger automatic searches for releases of different resources in your Sonarr instance" + )] + TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand), + #[command(about = "Clear the blocklist")] + ClearBlocklist, + #[command(about = "Mark the Sonarr history item with the given ID as 'failed'")] + MarkHistoryItemAsFailed { + #[arg( + long, + help = "The Sonarr ID of the history item you wish to mark as 'failed'", + required = true + )] + history_item_id: i64, + }, + #[command(about = "Search for a new series to add to Sonarr")] + SearchNewSeries { + #[arg( + long, + help = "The title of the series you want to search for", + required = true + )] + query: String, + }, + #[command(about = "Start the specified Sonarr task")] + StartTask { + #[arg( + long, + help = "The name of the task to trigger", + value_enum, + required = true + )] + task_name: SonarrTaskName, + }, + #[command( + about = "Test the indexer with the given ID. Note that a successful test returns an empty JSON body; i.e. '{}'" + )] + TestIndexer { + #[arg(long, help = "The ID of the indexer to test", required = true)] + indexer_id: i64, + }, + #[command(about = "Test all Sonarr indexers")] + TestAllIndexers, +} + +impl From for Command { + fn from(sonarr_command: SonarrCommand) -> Command { + Command::Sonarr(sonarr_command) + } +} + +pub(super) struct SonarrCliHandler<'a, 'b> { + app: &'a Arc>>, + command: SonarrCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrCommand> for SonarrCliHandler<'a, 'b> { + fn with( + app: &'a Arc>>, + command: SonarrCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrCliHandler { + app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrCommand::Add(add_command) => { + SonarrAddCommandHandler::with(self.app, add_command, self.network) + .handle() + .await? + } + SonarrCommand::Delete(delete_command) => { + SonarrDeleteCommandHandler::with(self.app, delete_command, self.network) + .handle() + .await? + } + SonarrCommand::Edit(edit_command) => { + SonarrEditCommandHandler::with(self.app, edit_command, self.network) + .handle() + .await? + } + SonarrCommand::Download(download_command) => { + SonarrDownloadCommandHandler::with(self.app, download_command, self.network) + .handle() + .await? + } + SonarrCommand::Get(get_command) => { + SonarrGetCommandHandler::with(self.app, get_command, self.network) + .handle() + .await? + } + SonarrCommand::List(list_command) => { + SonarrListCommandHandler::with(self.app, list_command, self.network) + .handle() + .await? + } + SonarrCommand::Refresh(refresh_command) => { + SonarrRefreshCommandHandler::with(self.app, refresh_command, self.network) + .handle() + .await? + } + SonarrCommand::ManualSearch(manual_search_command) => { + SonarrManualSearchCommandHandler::with(self.app, manual_search_command, self.network) + .handle() + .await? + } + SonarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => { + SonarrTriggerAutomaticSearchCommandHandler::with( + self.app, + trigger_automatic_search_command, + self.network, + ) + .handle() + .await? + } + SonarrCommand::ClearBlocklist => { + self + .network + .handle_network_event(SonarrEvent::GetBlocklist.into()) + .await?; + let resp = self + .network + .handle_network_event(SonarrEvent::ClearBlocklist.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrCommand::MarkHistoryItemAsFailed { history_item_id } => { + let _ = self + .network + .handle_network_event(SonarrEvent::MarkHistoryItemAsFailed(history_item_id).into()) + .await?; + "Sonarr history item marked as 'failed'".to_owned() + } + SonarrCommand::SearchNewSeries { query } => { + let resp = self + .network + .handle_network_event(SonarrEvent::SearchNewSeries(Some(query)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrCommand::StartTask { task_name } => { + let resp = self + .network + .handle_network_event(SonarrEvent::StartTask(Some(task_name)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrCommand::TestIndexer { indexer_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TestIndexer(Some(indexer_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrCommand::TestAllIndexers => { + println!("Testing all Sonarr indexers. This may take a minute..."); + let resp = self + .network + .handle_network_event(SonarrEvent::TestAllIndexers.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/refresh_command_handler.rs b/src/cli/sonarr/refresh_command_handler.rs new file mode 100644 index 0000000..418862f --- /dev/null +++ b/src/cli/sonarr/refresh_command_handler.rs @@ -0,0 +1,89 @@ +use std::sync::Arc; + +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "refresh_command_handler_tests.rs"] +mod refresh_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrRefreshCommand { + #[command(about = "Refresh all series data for all series in your Sonarr library")] + AllSeries, + #[command(about = "Refresh series data and scan disk for the series with the given ID")] + Series { + #[arg( + long, + help = "The ID of the series to refresh information on and to scan the disk for", + required = true + )] + series_id: i64, + }, + #[command(about = "Refresh all downloads in Sonarr")] + Downloads, +} + +impl From for Command { + fn from(value: SonarrRefreshCommand) -> Self { + Command::Sonarr(SonarrCommand::Refresh(value)) + } +} + +pub(super) struct SonarrRefreshCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrRefreshCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrRefreshCommand> + for SonarrRefreshCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrRefreshCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrRefreshCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> anyhow::Result { + let result = match self.command { + SonarrRefreshCommand::AllSeries => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateAllSeries.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrRefreshCommand::Series { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateAndScanSeries(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrRefreshCommand::Downloads => { + let resp = self + .network + .handle_network_event(SonarrEvent::UpdateDownloads.into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/refresh_command_handler_tests.rs b/src/cli/sonarr/refresh_command_handler_tests.rs new file mode 100644 index 0000000..ce133d2 --- /dev/null +++ b/src/cli/sonarr/refresh_command_handler_tests.rs @@ -0,0 +1,141 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::assert_eq; + + use crate::cli::{ + sonarr::{refresh_command_handler::SonarrRefreshCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + + #[test] + fn test_sonarr_refresh_command_from() { + let command = SonarrRefreshCommand::AllSeries; + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(SonarrCommand::Refresh(command))); + } + + mod cli { + use super::*; + use clap::{error::ErrorKind, Parser}; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_refresh_commands_have_no_arg_requirements( + #[values("all-series", "downloads")] subcommand: &str, + ) { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", subcommand]); + + assert!(result.is_ok()); + } + + #[test] + fn test_refresh_series_requires_series_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "refresh", "series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_refresh_series_success() { + let expected_args = SonarrRefreshCommand::Series { series_id: 1 }; + let result = Cli::try_parse_from([ + "managarr", + "sonarr", + "refresh", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + + if let Some(Command::Sonarr(SonarrCommand::Refresh(refresh_command))) = + result.unwrap().command + { + assert_eq!(refresh_command, expected_args); + } + } + } + + mod handler { + use rstest::rstest; + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{app::App, cli::sonarr::refresh_command_handler::SonarrRefreshCommandHandler}; + use crate::{ + cli::{sonarr::refresh_command_handler::SonarrRefreshCommand, CliCommandHandler}, + network::sonarr_network::SonarrEvent, + }; + use crate::{ + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{MockNetworkTrait, NetworkEvent}, + }; + + #[rstest] + #[case(SonarrRefreshCommand::AllSeries, SonarrEvent::UpdateAllSeries)] + #[case(SonarrRefreshCommand::Downloads, SonarrEvent::UpdateDownloads)] + #[tokio::test] + async fn test_handle_refresh_command( + #[case] refresh_command: SonarrRefreshCommand, + #[case] expected_sonarr_event: SonarrEvent, + ) { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(expected_sonarr_event.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + + let result = SonarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_handle_refresh_series_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let refresh_series_command = SonarrRefreshCommand::Series { series_id: 1 }; + + let result = + SonarrRefreshCommandHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/sonarr_command_tests.rs b/src/cli/sonarr/sonarr_command_tests.rs new file mode 100644 index 0000000..4490d23 --- /dev/null +++ b/src/cli/sonarr/sonarr_command_tests.rs @@ -0,0 +1,620 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{list_command_handler::SonarrListCommand, SonarrCommand}, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_command_from() { + let command = SonarrCommand::List(SonarrListCommand::Series); + + let result = Command::from(command.clone()); + + assert_eq!(result, Command::Sonarr(command)); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + use rstest::rstest; + + #[rstest] + fn test_commands_that_have_no_arg_requirements( + #[values("clear-blocklist", "test-all-indexers")] subcommand: &str, + ) { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", subcommand]); + + assert!(result.is_ok()); + } + + #[test] + fn test_mark_history_item_as_failed_requires_history_item_id() { + let result = + Cli::command().try_get_matches_from(["managarr", "sonarr", "mark-history-item-as-failed"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_mark_history_item_as_failed_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "mark-history-item-as-failed", + "--history-item-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_search_new_series_requires_query() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "search-new-series"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_search_new_series_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "search-new-series", + "--query", + "halo", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_start_task_requires_task_name() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "start-task"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_start_task_task_name_validation() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "start-task", + "--task-name", + "test", + ]); + + assert!(result.is_err()); + assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); + } + + #[test] + fn test_start_task_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "start-task", + "--task-name", + "application-update-check", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_test_indexer_requires_indexer_id() { + let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "test-indexer"]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_test_indexer_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "test-indexer", + "--indexer-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::{ + add_command_handler::SonarrAddCommand, delete_command_handler::SonarrDeleteCommand, + download_command_handler::SonarrDownloadCommand, edit_command_handler::SonarrEditCommand, + get_command_handler::SonarrGetCommand, list_command_handler::SonarrListCommand, + manual_search_command_handler::SonarrManualSearchCommand, + refresh_command_handler::SonarrRefreshCommand, + trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, + SonarrCliHandler, SonarrCommand, + }, + CliCommandHandler, + }, + models::{ + sonarr_models::{ + BlocklistItem, BlocklistResponse, IndexerSettings, Series, SonarrReleaseDownloadBody, + SonarrSerdeable, SonarrTaskName, + }, + Serdeable, + }, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_handle_clear_blocklist_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::BlocklistResponse( + BlocklistResponse { + records: vec![BlocklistItem::default()], + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ClearBlocklist.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let claer_blocklist_command = SonarrCommand::ClearBlocklist; + + let result = SonarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_mark_history_item_as_failed_command() { + let expected_history_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let mark_history_item_as_failed_command = + SonarrCommand::MarkHistoryItemAsFailed { history_item_id: 1 }; + + let result = SonarrCliHandler::with( + &app_arc, + mark_history_item_as_failed_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_add_commands_to_the_add_command_handler() { + let expected_tag_name = "test".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::AddTag(expected_tag_name.clone()).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let add_tag_command = SonarrCommand::Add(SonarrAddCommand::Tag { + name: expected_tag_name, + }); + + let result = SonarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() { + let expected_blocklist_item_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DeleteBlocklistItem(Some(expected_blocklist_item_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let delete_blocklist_item_command = + SonarrCommand::Delete(SonarrDeleteCommand::BlocklistItem { + blocklist_item_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, delete_blocklist_item_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_download_commands_to_the_download_command_handler() { + let expected_params = SonarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 1, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::DownloadRelease(expected_params).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let download_series_release_command = + SonarrCommand::Download(SonarrDownloadCommand::Series { + guid: "1234".to_owned(), + indexer_id: 1, + series_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, download_series_release_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() { + let expected_edit_all_indexer_settings = IndexerSettings { + id: 1, + maximum_size: 1, + minimum_age: 1, + retention: 1, + rss_sync_interval: 1, + }; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetAllIndexerSettings.into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::IndexerSettings( + IndexerSettings { + id: 1, + maximum_size: 2, + minimum_age: 2, + retention: 2, + rss_sync_interval: 2, + }, + ))) + }); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::EditAllIndexerSettings(Some(expected_edit_all_indexer_settings)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let edit_all_indexer_settings_command = + SonarrCommand::Edit(SonarrEditCommand::AllIndexerSettings { + maximum_size: Some(1), + minimum_age: Some(1), + retention: Some(1), + rss_sync_interval: Some(1), + }); + + let result = SonarrCliHandler::with( + &app_arc, + edit_all_indexer_settings_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler( + ) { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::GetEpisodeReleases(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = + SonarrCommand::ManualSearch(SonarrManualSearchCommand::Episode { episode_id: 1 }); + + let result = + SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler( + ) { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let manual_episode_search_command = + SonarrCommand::TriggerAutomaticSearch(SonarrTriggerAutomaticSearchCommand::Episode { + episode_id: 1, + }); + + let result = + SonarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_get_commands_to_the_get_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::GetStatus.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let get_system_status_command = SonarrCommand::Get(SonarrGetCommand::SystemStatus); + + let result = SonarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_list_commands_to_the_list_command_handler() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::ListSeries.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::SeriesVec(vec![ + Series::default(), + ]))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let list_series_command = SonarrCommand::List(SonarrListCommand::Series); + + let result = SonarrCliHandler::with(&app_arc, list_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_sonarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::UpdateAndScanSeries(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let refresh_series_command = + SonarrCommand::Refresh(SonarrRefreshCommand::Series { series_id: 1 }); + + let result = SonarrCliHandler::with(&app_arc, refresh_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_search_new_series_command() { + let expected_search_query = "halo".to_owned(); + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::SearchNewSeries(Some(expected_search_query)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let search_new_series_command = SonarrCommand::SearchNewSeries { + query: "halo".to_owned(), + }; + + let result = SonarrCliHandler::with(&app_arc, search_new_series_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_start_task_command() { + let expected_task_name = SonarrTaskName::ApplicationUpdateCheck; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::StartTask(Some(expected_task_name)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let start_task_command = SonarrCommand::StartTask { + task_name: SonarrTaskName::ApplicationUpdateCheck, + }; + + let result = SonarrCliHandler::with(&app_arc, start_task_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_test_indexer_command() { + let expected_indexer_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TestIndexer(Some(expected_indexer_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let test_indexer_command = SonarrCommand::TestIndexer { indexer_id: 1 }; + + let result = SonarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_test_all_indexers_command() { + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::(SonarrEvent::TestAllIndexers.into())) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let test_all_indexers_command = SonarrCommand::TestAllIndexers; + + let result = SonarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/cli/sonarr/trigger_automatic_search_command_handler.rs b/src/cli/sonarr/trigger_automatic_search_command_handler.rs new file mode 100644 index 0000000..e87a5a5 --- /dev/null +++ b/src/cli/sonarr/trigger_automatic_search_command_handler.rs @@ -0,0 +1,113 @@ +use std::sync::Arc; + +use anyhow::Result; +use clap::Subcommand; +use tokio::sync::Mutex; + +use crate::{ + app::App, + cli::{CliCommandHandler, Command}, + network::{sonarr_network::SonarrEvent, NetworkTrait}, +}; + +use super::SonarrCommand; + +#[cfg(test)] +#[path = "trigger_automatic_search_command_handler_tests.rs"] +mod trigger_automatic_search_command_handler_tests; + +#[derive(Debug, Clone, PartialEq, Eq, Subcommand)] +pub enum SonarrTriggerAutomaticSearchCommand { + #[command(about = "Trigger an automatic search for the series with the specified ID")] + Series { + #[arg( + long, + help = "The ID of the series you want to trigger an automatic search for", + required = true + )] + series_id: i64, + }, + #[command( + about = "Trigger an automatic search for the given season corresponding to the series with the given ID" + )] + Season { + #[arg( + long, + help = "The Sonarr ID of the series whose season you wish to trigger an automatic search for", + required = true + )] + series_id: i64, + #[arg(long, help = "The season number to search for", required = true)] + season_number: i64, + }, + #[command(about = "Trigger an automatic search for the episode with the specified ID")] + Episode { + #[arg( + long, + help = "The ID of the episode you want to trigger an automatic search for", + required = true + )] + episode_id: i64, + }, +} + +impl From for Command { + fn from(value: SonarrTriggerAutomaticSearchCommand) -> Self { + Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(value)) + } +} + +pub(super) struct SonarrTriggerAutomaticSearchCommandHandler<'a, 'b> { + _app: &'a Arc>>, + command: SonarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, +} + +impl<'a, 'b> CliCommandHandler<'a, 'b, SonarrTriggerAutomaticSearchCommand> + for SonarrTriggerAutomaticSearchCommandHandler<'a, 'b> +{ + fn with( + _app: &'a Arc>>, + command: SonarrTriggerAutomaticSearchCommand, + network: &'a mut dyn NetworkTrait, + ) -> Self { + SonarrTriggerAutomaticSearchCommandHandler { + _app, + command, + network, + } + } + + async fn handle(self) -> Result { + let result = match self.command { + SonarrTriggerAutomaticSearchCommand::Series { series_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(series_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrTriggerAutomaticSearchCommand::Season { + series_id, + season_number, + } => { + let resp = self + .network + .handle_network_event( + SonarrEvent::TriggerAutomaticSeasonSearch(Some((series_id, season_number))).into(), + ) + .await?; + serde_json::to_string_pretty(&resp)? + } + SonarrTriggerAutomaticSearchCommand::Episode { episode_id } => { + let resp = self + .network + .handle_network_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(episode_id)).into()) + .await?; + serde_json::to_string_pretty(&resp)? + } + }; + + Ok(result) + } +} diff --git a/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs b/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs new file mode 100644 index 0000000..03c4057 --- /dev/null +++ b/src/cli/sonarr/trigger_automatic_search_command_handler_tests.rs @@ -0,0 +1,259 @@ +#[cfg(test)] +mod tests { + use crate::cli::{ + sonarr::{ + trigger_automatic_search_command_handler::SonarrTriggerAutomaticSearchCommand, SonarrCommand, + }, + Command, + }; + use crate::Cli; + use clap::CommandFactory; + use pretty_assertions::assert_eq; + + #[test] + fn test_sonarr_trigger_automatic_search_command_from() { + let command = SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 }; + + let result = Command::from(command.clone()); + + assert_eq!( + result, + Command::Sonarr(SonarrCommand::TriggerAutomaticSearch(command)) + ); + } + + mod cli { + use super::*; + use clap::error::ErrorKind; + use pretty_assertions::assert_eq; + + #[test] + fn test_trigger_automatic_series_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "series", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_series_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "series", + "--series-id", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_trigger_automatic_season_search_requires_series_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--season-number", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_season_search_requires_season_number() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--series-id", + "1", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_season_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "season", + "--series-id", + "1", + "--season-number", + "1", + ]); + + assert!(result.is_ok()); + } + + #[test] + fn test_trigger_automatic_episode_search_requires_episode_id() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "episode", + ]); + + assert!(result.is_err()); + assert_eq!( + result.unwrap_err().kind(), + ErrorKind::MissingRequiredArgument + ); + } + + #[test] + fn test_trigger_automatic_episode_search_requirements_satisfied() { + let result = Cli::command().try_get_matches_from([ + "managarr", + "sonarr", + "trigger-automatic-search", + "episode", + "--episode-id", + "1", + ]); + + assert!(result.is_ok()); + } + } + + mod handler { + use std::sync::Arc; + + use mockall::predicate::eq; + use serde_json::json; + use tokio::sync::Mutex; + + use crate::{ + app::App, + cli::{ + sonarr::trigger_automatic_search_command_handler::{ + SonarrTriggerAutomaticSearchCommand, SonarrTriggerAutomaticSearchCommandHandler, + }, + CliCommandHandler, + }, + models::{sonarr_models::SonarrSerdeable, Serdeable}, + network::{sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent}, + }; + + #[tokio::test] + async fn test_trigger_automatic_series_search_command() { + let expected_series_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeriesSearch(Some(expected_series_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_series_search_command = + SonarrTriggerAutomaticSearchCommand::Series { series_id: 1 }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_series_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_trigger_automatic_season_search_command() { + let expected_series_id = 1; + let expected_season_number = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticSeasonSearch(Some(( + expected_series_id, + expected_season_number, + ))) + .into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_season_search_command = SonarrTriggerAutomaticSearchCommand::Season { + series_id: 1, + season_number: 1, + }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_season_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_trigger_automatic_episode_search_command() { + let expected_episode_id = 1; + let mut mock_network = MockNetworkTrait::new(); + mock_network + .expect_handle_network_event() + .with(eq::( + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(expected_episode_id)).into(), + )) + .times(1) + .returning(|_| { + Ok(Serdeable::Sonarr(SonarrSerdeable::Value( + json!({"testResponse": "response"}), + ))) + }); + let app_arc = Arc::new(Mutex::new(App::default())); + let trigger_automatic_episode_search_command = + SonarrTriggerAutomaticSearchCommand::Episode { episode_id: 1 }; + + let result = SonarrTriggerAutomaticSearchCommandHandler::with( + &app_arc, + trigger_automatic_episode_search_command, + &mut mock_network, + ) + .handle() + .await; + + assert!(result.is_ok()); + } + } +} diff --git a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs index 2dd8551..67d7dbf 100644 --- a/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs +++ b/src/handlers/radarr_handlers/blocklist/blocklist_handler_tests.rs @@ -11,10 +11,9 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::blocklist::{blocklist_sorting_options, BlocklistHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, Language, Quality, QualityWrapper, - }; + use crate::models::radarr_models::{BlocklistItem, BlocklistItemMovie}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, BLOCKLIST_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::stateful_table::SortOption; mod test_handle_scroll_up_and_down { @@ -960,6 +959,7 @@ mod tests { id: 3, source_title: "test 1".to_owned(), languages: vec![Language { + id: 1, name: "telgu".to_owned(), }], quality: QualityWrapper { @@ -968,6 +968,7 @@ mod tests { }, }, custom_formats: Some(vec![Language { + id: 2, name: "nikki".to_owned(), }]), date: DateTime::from(DateTime::parse_from_rfc3339("2024-01-10T07:28:45Z").unwrap()), @@ -980,6 +981,7 @@ mod tests { id: 2, source_title: "test 2".to_owned(), languages: vec![Language { + id: 3, name: "chinese".to_owned(), }], quality: QualityWrapper { @@ -989,9 +991,11 @@ mod tests { }, custom_formats: Some(vec![ Language { + id: 4, name: "alex".to_owned(), }, Language { + id: 5, name: "English".to_owned(), }, ]), @@ -1005,6 +1009,7 @@ mod tests { id: 1, source_title: "test 3".to_owned(), languages: vec![Language { + id: 1, name: "english".to_owned(), }], quality: QualityWrapper { @@ -1013,6 +1018,7 @@ mod tests { }, }, custom_formats: Some(vec![Language { + id: 2, name: "English".to_owned(), }]), date: DateTime::from(DateTime::parse_from_rfc3339("2024-03-10T07:28:45Z").unwrap()), diff --git a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs index 057b5ab..8c33809 100644 --- a/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/edit_indexer_handler_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::edit_indexer_handler::EditIndexerHandler; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, EDIT_INDEXER_BLOCKS}; use strum::IntoEnumIterator; @@ -14,7 +14,7 @@ mod tests { use pretty_assertions::assert_eq; use rstest::rstest; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; use crate::models::BlockSelectionState; @@ -69,7 +69,7 @@ mod tests { use std::sync::atomic::Ordering; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use pretty_assertions::assert_eq; use super::*; @@ -334,7 +334,7 @@ mod tests { use std::sync::atomic::Ordering; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, }; @@ -759,7 +759,7 @@ mod tests { use rstest::rstest; use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::{ servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, BlockSelectionState, }; @@ -1224,7 +1224,7 @@ mod tests { use super::*; use crate::app::App; use crate::event::Key; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use pretty_assertions::assert_eq; use rstest::rstest; @@ -1281,7 +1281,7 @@ mod tests { mod test_handle_key_char { use crate::app::App; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::EDIT_INDEXER_TORRENT_SELECTION_BLOCKS; use crate::models::BlockSelectionState; use crate::network::radarr_network::RadarrEvent; diff --git a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs index 1d3d59e..5fd3b1d 100644 --- a/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/indexers_handler_tests.rs @@ -9,16 +9,15 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::IndexersHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::Indexer; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_BLOCKS, }; + use crate::models::servarr_models::Indexer; use crate::test_handler_delegation; mod test_handle_scroll_up_and_down { use rstest::rstest; - use crate::models::radarr_models::Indexer; use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; use super::*; @@ -65,7 +64,6 @@ mod tests { } mod test_handle_home_end { - use crate::models::radarr_models::Indexer; use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; use super::*; @@ -239,11 +237,11 @@ mod tests { } mod test_handle_submit { - use crate::models::radarr_models::{Indexer, IndexerField}; - use crate::models::servarr_data::radarr::modals::EditIndexerModal; + use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::{ RadarrData, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, }; + use crate::models::servarr_models::{Indexer, IndexerField}; use bimap::BiMap; use pretty_assertions::assert_eq; use serde_json::{Number, Value}; diff --git a/src/handlers/radarr_handlers/indexers/mod.rs b/src/handlers/radarr_handlers/indexers/mod.rs index f8d00fc..84d4832 100644 --- a/src/handlers/radarr_handlers/indexers/mod.rs +++ b/src/handlers/radarr_handlers/indexers/mod.rs @@ -10,7 +10,8 @@ use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_INDEXER_NZB_SELECTION_BLOCKS, EDIT_INDEXER_TORRENT_SELECTION_BLOCKS, INDEXERS_BLOCKS, INDEXER_SETTINGS_SELECTION_BLOCKS, }; -use crate::models::{BlockSelectionState, Scrollable}; +use crate::models::BlockSelectionState; +use crate::models::Scrollable; use crate::network::radarr_network::RadarrEvent; mod edit_indexer_handler; diff --git a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs index 4ac81cc..708e22d 100644 --- a/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs +++ b/src/handlers/radarr_handlers/indexers/test_all_indexers_handler_tests.rs @@ -5,7 +5,7 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::indexers::test_all_indexers_handler::TestAllIndexersHandler; use crate::handlers::KeyEventHandler; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::stateful_table::StatefulTable; use strum::IntoEnumIterator; @@ -14,7 +14,7 @@ mod tests { use pretty_assertions::assert_str_eq; use rstest::rstest; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::stateful_table::StatefulTable; use crate::simple_stateful_iterable_vec; @@ -112,7 +112,7 @@ mod tests { mod test_handle_home_end { use crate::extended_stateful_iterable_vec; - use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; + use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::stateful_table::StatefulTable; use pretty_assertions::assert_str_eq; diff --git a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs index 7da2d8f..2800832 100644 --- a/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/add_movie_handler_tests.rs @@ -8,10 +8,9 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::add_movie_handler::AddMovieHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - AddMovieSearchResult, MinimumAvailability, Monitor, RootFolder, - }; + use crate::models::radarr_models::{AddMovieSearchResult, MinimumAvailability, MovieMonitor}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; + use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; mod test_handle_scroll_up_and_down { @@ -142,7 +141,7 @@ mod tests { fn test_add_movie_select_monitor_scroll( #[values(DEFAULT_KEYBINDINGS.up.key, DEFAULT_KEYBINDINGS.down.key)] key: Key, ) { - let monitor_vec = Vec::from_iter(Monitor::iter()); + let monitor_vec = Vec::from_iter(MovieMonitor::iter()); let mut app = App::default(); app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); app @@ -535,7 +534,7 @@ mod tests { #[test] fn test_add_movie_select_monitor_home_end() { - let monitor_vec = Vec::from_iter(Monitor::iter()); + let monitor_vec = Vec::from_iter(MovieMonitor::iter()); let mut app = App::default(); app.data.radarr_data.add_movie_modal = Some(AddMovieModal::default()); app diff --git a/src/handlers/radarr_handlers/library/library_handler_tests.rs b/src/handlers/radarr_handlers/library/library_handler_tests.rs index c5711b9..22a4cd1 100644 --- a/src/handlers/radarr_handlers/library/library_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/library_handler_tests.rs @@ -11,11 +11,12 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::library::{movies_sorting_options, LibraryHandler}; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{Language, Movie}; + use crate::models::radarr_models::Movie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, ADD_MOVIE_BLOCKS, DELETE_MOVIE_BLOCKS, EDIT_MOVIE_BLOCKS, LIBRARY_BLOCKS, MOVIE_DETAILS_BLOCKS, }; + use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; use crate::test_handler_delegation; @@ -1806,6 +1807,7 @@ mod tests { id: 3, title: "test 1".into(), original_language: Language { + id: 1, name: "English".to_owned(), }, size_on_disk: 1024, @@ -1822,6 +1824,7 @@ mod tests { id: 2, title: "test 2".into(), original_language: Language { + id: 2, name: "Chinese".to_owned(), }, size_on_disk: 2048, @@ -1838,6 +1841,7 @@ mod tests { id: 1, title: "test 3".into(), original_language: Language { + id: 3, name: "Japanese".to_owned(), }, size_on_disk: 512, diff --git a/src/handlers/radarr_handlers/library/movie_details_handler.rs b/src/handlers/radarr_handlers/library/movie_details_handler.rs index 35f42dc..097599a 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler.rs @@ -4,10 +4,11 @@ use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::App; use crate::event::Key; use crate::handlers::{handle_prompt_toggle, KeyEventHandler}; -use crate::models::radarr_models::{Language, Release}; +use crate::models::radarr_models::RadarrRelease; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_SELECTION_BLOCKS, MOVIE_DETAILS_BLOCKS, }; +use crate::models::servarr_models::Language; use crate::models::stateful_table::SortOption; use crate::models::{BlockSelectionState, Scrollable}; use crate::network::radarr_network::RadarrEvent; @@ -505,7 +506,7 @@ impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveRadarrBlock> for MovieDetailsHandler< } } -fn releases_sorting_options() -> Vec> { +fn releases_sorting_options() -> Vec> { vec![ SortOption { name: "Source", @@ -560,6 +561,7 @@ fn releases_sorting_options() -> Vec> { name: "Language", cmp_fn: Some(|a, b| { let default_language_vec = vec![Language { + id: 1, name: "_".to_owned(), }]; let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; diff --git a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs index e9c45b4..e789ad4 100644 --- a/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/library/movie_details_handler_tests.rs @@ -14,11 +14,11 @@ mod tests { releases_sorting_options, MovieDetailsHandler, }; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{ - Credit, Language, MovieHistoryItem, Quality, QualityWrapper, Release, - }; + use crate::models::radarr_models::RadarrRelease; + use crate::models::radarr_models::{Credit, MovieHistoryItem}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; + use crate::models::servarr_models::{Language, Quality, QualityWrapper}; use crate::models::stateful_table::SortOption; use crate::models::{HorizontallyScrollableText, ScrollableText}; @@ -406,7 +406,7 @@ mod tests { movie_details_modal .movie_releases .set_items(simple_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -454,7 +454,7 @@ mod tests { movie_details_modal .movie_releases .set_items(simple_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -997,7 +997,7 @@ mod tests { movie_details_modal .movie_releases .set_items(extended_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -1055,7 +1055,7 @@ mod tests { movie_details_modal .movie_releases .set_items(extended_stateful_iterable_vec!( - Release, + RadarrRelease, HorizontallyScrollableText )); app.data.radarr_data.movie_details_modal = Some(movie_details_modal); @@ -1250,7 +1250,9 @@ mod tests { movie_details: ScrollableText::with_string("test".to_owned()), ..MovieDetailsModal::default() }; - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); app.push_navigation_stack(ActiveRadarrBlock::ManualSearch.into()); @@ -1488,6 +1490,8 @@ mod tests { )] active_radarr_block: ActiveRadarrBlock, ) { + use crate::models::radarr_models::RadarrRelease; + let mut app = App::default(); let mut modal = MovieDetailsModal { movie_details: ScrollableText::with_string("Test".to_owned()), @@ -1498,7 +1502,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1688,7 +1694,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1758,7 +1766,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); MovieDetailsHandler::with( @@ -1852,7 +1862,8 @@ mod tests { #[test] fn test_releases_sorting_options_source() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.protocol.cmp(&b.protocol); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.protocol.cmp(&b.protocol); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1866,7 +1877,7 @@ mod tests { #[test] fn test_releases_sorting_options_age() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.age.cmp(&b.age); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| a.age.cmp(&b.age); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1880,7 +1891,8 @@ mod tests { #[test] fn test_releases_sorting_options_rejected() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.rejected.cmp(&b.rejected); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.rejected.cmp(&b.rejected); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1894,7 +1906,7 @@ mod tests { #[test] fn test_releases_sorting_options_title() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { a.title .text .to_lowercase() @@ -1913,7 +1925,7 @@ mod tests { #[test] fn test_releases_sorting_options_indexer() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| a.indexer.to_lowercase().cmp(&b.indexer.to_lowercase()); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1928,7 +1940,8 @@ mod tests { #[test] fn test_releases_sorting_options_size() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.size.cmp(&b.size); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.size.cmp(&b.size); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -1942,7 +1955,7 @@ mod tests { #[test] fn test_releases_sorting_options_peers() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { let default_number = Number::from(i64::MAX); let seeder_a = a .seeders @@ -1972,8 +1985,9 @@ mod tests { #[test] fn test_releases_sorting_options_language() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| { + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = |a, b| { let default_language_vec = vec![Language { + id: 1, name: "_".to_owned(), }]; let language_a = &a.languages.as_ref().unwrap_or(&default_language_vec)[0]; @@ -1994,7 +2008,8 @@ mod tests { #[test] fn test_releases_sorting_options_quality() { - let expected_cmp_fn: fn(&Release, &Release) -> Ordering = |a, b| a.quality.cmp(&b.quality); + let expected_cmp_fn: fn(&RadarrRelease, &RadarrRelease) -> Ordering = + |a, b| a.quality.cmp(&b.quality); let mut expected_releases_vec = release_vec(); expected_releases_vec.sort_by(expected_cmp_fn); @@ -2041,7 +2056,9 @@ mod tests { .set_items(vec![MovieHistoryItem::default()]); modal.movie_cast.set_items(vec![Credit::default()]); modal.movie_crew.set_items(vec![Credit::default()]); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( @@ -2150,7 +2167,9 @@ mod tests { let mut app = App::default(); app.is_loading = false; let mut modal = MovieDetailsModal::default(); - modal.movie_releases.set_items(vec![Release::default()]); + modal + .movie_releases + .set_items(vec![RadarrRelease::default()]); app.data.radarr_data.movie_details_modal = Some(modal); let handler = MovieDetailsHandler::with( @@ -2163,8 +2182,8 @@ mod tests { assert!(handler.is_ready()); } - fn release_vec() -> Vec { - let release_a = Release { + fn release_vec() -> Vec { + let release_a = RadarrRelease { protocol: "Protocol A".to_owned(), age: 1, title: HorizontallyScrollableText::from("Title A"), @@ -2173,6 +2192,7 @@ mod tests { rejected: true, seeders: Some(Number::from(1)), languages: Some(vec![Language { + id: 1, name: "Language A".to_owned(), }]), quality: QualityWrapper { @@ -2180,9 +2200,9 @@ mod tests { name: "Quality A".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; - let release_b = Release { + let release_b = RadarrRelease { protocol: "Protocol B".to_owned(), age: 2, title: HorizontallyScrollableText::from("title B"), @@ -2191,6 +2211,7 @@ mod tests { rejected: false, seeders: Some(Number::from(2)), languages: Some(vec![Language { + id: 2, name: "Language B".to_owned(), }]), quality: QualityWrapper { @@ -2198,9 +2219,9 @@ mod tests { name: "Quality B".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; - let release_c = Release { + let release_c = RadarrRelease { protocol: "Protocol C".to_owned(), age: 3, title: HorizontallyScrollableText::from("Title C"), @@ -2214,13 +2235,13 @@ mod tests { name: "Quality C".to_owned(), }, }, - ..Release::default() + ..RadarrRelease::default() }; vec![release_a, release_b, release_c] } - fn sort_options() -> Vec> { + fn sort_options() -> Vec> { vec![SortOption { name: "Test 1", cmp_fn: Some(|a, b| a.age.cmp(&b.age)), diff --git a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs index 8a62dde..fc9bd65 100644 --- a/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs +++ b/src/handlers/radarr_handlers/root_folders/root_folders_handler_tests.rs @@ -8,14 +8,14 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::root_folders::RootFoldersHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::RootFolder; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; + use crate::models::servarr_models::RootFolder; use crate::models::HorizontallyScrollableText; mod test_handle_scroll_up_and_down { use rstest::rstest; - use crate::models::radarr_models::RootFolder; + use crate::models::servarr_models::RootFolder; use crate::{simple_stateful_iterable_vec, test_iterable_scroll}; use super::*; @@ -63,7 +63,7 @@ mod tests { use pretty_assertions::assert_eq; - use crate::models::radarr_models::RootFolder; + use crate::models::servarr_models::RootFolder; use crate::{extended_stateful_iterable_vec, test_iterable_home_and_end}; use super::*; diff --git a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs index c8947a8..65c9497 100644 --- a/src/handlers/radarr_handlers/system/system_details_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_details_handler_tests.rs @@ -8,10 +8,11 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::system_details_handler::SystemDetailsHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{QueueEvent, Task}; + use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; + use crate::models::servarr_models::QueueEvent; use crate::models::{HorizontallyScrollableText, ScrollableText}; mod test_handle_scroll_up_and_down { @@ -73,7 +74,7 @@ mod tests { .data .radarr_data .tasks - .set_items(simple_stateful_iterable_vec!(Task, String, name)); + .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); @@ -101,7 +102,7 @@ mod tests { .data .radarr_data .tasks - .set_items(simple_stateful_iterable_vec!(Task, String, name)); + .set_items(simple_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with(&key, &mut app, &ActiveRadarrBlock::SystemTasks, &None).handle(); @@ -317,7 +318,7 @@ mod tests { .data .radarr_data .tasks - .set_items(extended_stateful_iterable_vec!(Task, String, name)); + .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( &DEFAULT_KEYBINDINGS.end.key, @@ -356,7 +357,7 @@ mod tests { .data .radarr_data .tasks - .set_items(extended_stateful_iterable_vec!(Task, String, name)); + .set_items(extended_stateful_iterable_vec!(RadarrTask, String, name)); SystemDetailsHandler::with( &DEFAULT_KEYBINDINGS.end.key, @@ -788,7 +789,11 @@ mod tests { app.is_loading = is_ready; app.push_navigation_stack(ActiveRadarrBlock::System.into()); app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into()); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemDetailsHandler::with(&ESC_KEY, &mut app, &ActiveRadarrBlock::SystemTasks, &None) .handle(); diff --git a/src/handlers/radarr_handlers/system/system_handler_tests.rs b/src/handlers/radarr_handlers/system/system_handler_tests.rs index 5f2234d..03b3aec 100644 --- a/src/handlers/radarr_handlers/system/system_handler_tests.rs +++ b/src/handlers/radarr_handlers/system/system_handler_tests.rs @@ -9,10 +9,11 @@ mod tests { use crate::event::Key; use crate::handlers::radarr_handlers::system::SystemHandler; use crate::handlers::KeyEventHandler; - use crate::models::radarr_models::{QueueEvent, Task}; + use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS, }; + use crate::models::servarr_models::QueueEvent; use crate::test_handler_delegation; mod test_handle_left_right_action { @@ -104,7 +105,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -134,7 +139,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -159,7 +168,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.events.key, @@ -189,7 +202,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.events.key, @@ -214,7 +231,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( @@ -243,7 +264,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app.push_navigation_stack(ActiveRadarrBlock::System.into()); SystemHandler::with( @@ -270,7 +295,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.logs.key, @@ -308,7 +337,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.logs.key, @@ -334,7 +367,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.tasks.key, @@ -364,7 +401,11 @@ mod tests { .radarr_data .queued_events .set_items(vec![QueueEvent::default()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); SystemHandler::with( &DEFAULT_KEYBINDINGS.tasks.key, @@ -429,7 +470,11 @@ mod tests { fn test_system_handler_is_not_ready_when_logs_is_empty() { let mut app = App::default(); app.is_loading = false; - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app .data .radarr_data @@ -472,7 +517,11 @@ mod tests { let mut app = App::default(); app.is_loading = false; app.data.radarr_data.logs.set_items(vec!["test".into()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); let system_handler = SystemHandler::with( &DEFAULT_KEYBINDINGS.update.key, @@ -489,7 +538,11 @@ mod tests { let mut app = App::default(); app.is_loading = false; app.data.radarr_data.logs.set_items(vec!["test".into()]); - app.data.radarr_data.tasks.set_items(vec![Task::default()]); + app + .data + .radarr_data + .tasks + .set_items(vec![RadarrTask::default()]); app .data .radarr_data diff --git a/src/main.rs b/src/main.rs index 388286b..d828b28 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,22 +1,14 @@ #![warn(rust_2018_idioms)] -use std::fs::{self, File}; -use std::io::BufReader; +use anyhow::Result; use std::panic::PanicHookInfo; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; -use std::time::Duration; use std::{io, panic, process}; -use anyhow::anyhow; -use anyhow::Result; -use app::{log_and_print_error, AppConfig, ServarrConfig}; -use clap::{ - command, crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser, -}; +use clap::{crate_authors, crate_description, crate_name, crate_version, CommandFactory, Parser}; use clap_complete::generate; -use colored::Colorize; use crossterm::execute; use crossterm::terminal::{ disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, @@ -25,12 +17,14 @@ use log::{error, warn}; use network::NetworkTrait; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; -use reqwest::{Certificate, Client}; +use reqwest::Client; use tokio::select; use tokio::sync::mpsc::Receiver; use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; -use utils::tail_logs; +use utils::{ + build_network_client, load_config, start_cli_no_spinner, start_cli_with_spinner, tail_logs, +}; use crate::app::App; use crate::cli::Command; @@ -67,6 +61,13 @@ mod utils; struct Cli { #[command(subcommand)] command: Option, + #[arg( + long, + global = true, + env = "MANAGARR_DISABLE_SPINNER", + help = "Disable the spinner (can sometimes make parsing output challenging)" + )] + disable_spinner: bool, #[arg( long, global = true, @@ -91,6 +92,7 @@ async fn main() -> Result<()> { } else { confy::load("managarr", "config")? }; + let spinner_disabled = args.disable_spinner; config.validate(); let reqwest_client = build_network_client(&config); let (sync_network_tx, sync_network_rx) = mpsc::channel(500); @@ -106,19 +108,17 @@ async fn main() -> Result<()> { let app = Arc::new(Mutex::new(App::new( sync_network_tx, - config, + config.clone(), cancellation_token.clone(), ))); match args.command { Some(command) => match command { - Command::Radarr(_) => { - let app_nw = Arc::clone(&app); - let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); - - if let Err(e) = cli::handle_command(&app, command, &mut network).await { - eprintln!("error: {}", e.to_string().red()); - process::exit(1); + Command::Radarr(_) | Command::Sonarr(_) => { + if spinner_disabled { + start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await; + } else { + start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await; } } Command::Completions { shell } => { @@ -235,66 +235,11 @@ fn panic_hook(info: &PanicHookInfo<'_>) { .unwrap(); } -fn load_config(path: &str) -> Result { - let file = File::open(path).map_err(|e| anyhow!(e))?; - let reader = BufReader::new(file); - let config = serde_yaml::from_reader(reader)?; - Ok(config) -} - -fn build_network_client(config: &AppConfig) -> Client { - let mut client_builder = Client::builder() - .pool_max_idle_per_host(10) - .http2_keep_alive_interval(Duration::from_secs(5)) - .tcp_keepalive(Duration::from_secs(5)); - - if let Some(ref cert_path) = config.radarr.ssl_cert_path { - let cert = create_cert(cert_path, "Radarr"); - client_builder = client_builder.add_root_certificate(cert); - } - - match client_builder.build() { - Ok(client) => client, - Err(e) => { - error!("Unable to create reqwest client: {}", e); - eprintln!("error: {}", e.to_string().red()); - process::exit(1); - } - } -} - -fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { - match fs::read(cert_path) { - Ok(cert) => match Certificate::from_pem(&cert) { - Ok(certificate) => certificate, - Err(_) => { - log_and_print_error(format!( - "Unable to read the specified {} SSL certificate", - servarr_name - )); - process::exit(1); - } - }, - Err(_) => { - log_and_print_error(format!( - "Unable to open specified {} SSL certificate", - servarr_name - )); - process::exit(1); - } - } -} - #[cfg(not(debug_assertions))] fn panic_hook(info: &PanicHookInfo<'_>) { - use human_panic::{handle_dump, print_msg, Metadata}; + use human_panic::{handle_dump, metadata, print_msg}; - let meta = Metadata { - version: env!("CARGO_PKG_VERSION").into(), - name: env!("CARGO_PKG_NAME").into(), - authors: env!("CARGO_PKG_AUTHORS").replace(":", ", ").into(), - homepage: env!("CARGO_PKG_HOMEPAGE").into(), - }; + let meta = metadata!(); let file_path = handle_dump(&meta, info); disable_raw_mode().unwrap(); execute!(io::stdout(), LeaveAlternateScreen).unwrap(); diff --git a/src/models/mod.rs b/src/models/mod.rs index 7193066..d0aa2c9 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -6,10 +6,15 @@ use radarr_models::RadarrSerdeable; use regex::Regex; use serde::{de, Deserialize, Deserializer, Serialize, Serializer}; use serde_json::Number; +use servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; +use sonarr_models::SonarrSerdeable; pub mod radarr_models; pub mod servarr_data; +pub mod servarr_models; +pub mod sonarr_models; pub mod stateful_list; pub mod stateful_table; +pub mod stateful_tree; #[cfg(test)] #[path = "model_tests.rs"] @@ -20,7 +25,7 @@ mod model_tests; #[derive(Clone, Copy, PartialEq, Eq, Debug)] pub enum Route { Radarr(ActiveRadarrBlock, Option), - Sonarr, + Sonarr(ActiveSonarrBlock, Option), Readarr, Lidarr, Whisparr, @@ -33,6 +38,11 @@ pub enum Route { #[serde(untagged)] pub enum Serdeable { Radarr(RadarrSerdeable), + Sonarr(SonarrSerdeable), +} + +pub trait EnumDisplayStyle<'a> { + fn to_display_str(self) -> &'a str; } pub trait Scrollable { @@ -359,6 +369,16 @@ where ))) } +pub fn from_f64<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let num: Number = Deserialize::deserialize(deserializer)?; + num.as_f64().ok_or(de::Error::custom(format!( + "Unable to convert Number to f64: {num:?}" + ))) +} + pub fn strip_non_search_characters(input: &str) -> String { Regex::new(r"[^a-zA-Z0-9.,/'\-:\s]") .unwrap() diff --git a/src/models/model_tests.rs b/src/models/model_tests.rs index 7cd2696..58880fc 100644 --- a/src/models/model_tests.rs +++ b/src/models/model_tests.rs @@ -10,6 +10,7 @@ mod tests { use serde::de::IntoDeserializer; use serde_json::to_string; + use crate::models::from_f64; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::{from_i64, strip_non_search_characters}; use crate::models::{ @@ -649,6 +650,13 @@ mod tests { ); } + #[test] + fn test_from_f64() { + let deserializer: F64Deserializer = 1f64.into_deserializer(); + + assert_eq!(from_f64(deserializer), Ok(1.0)); + } + #[test] fn test_horizontally_scrollable_serialize() { let text = HorizontallyScrollableText::from("Test"); diff --git a/src/models/radarr_models.rs b/src/models/radarr_models.rs index 225d338..a99974e 100644 --- a/src/models/radarr_models.rs +++ b/src/models/radarr_models.rs @@ -9,7 +9,11 @@ use strum_macros::EnumIter; use crate::{models::HorizontallyScrollableText, serde_enum_from}; -use super::Serdeable; +use super::servarr_models::{ + DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, +}; +use super::{EnumDisplayStyle, Serdeable}; #[cfg(test)] #[path = "radarr_models_tests.rs"] @@ -25,7 +29,7 @@ pub struct AddMovieBody { pub minimum_availability: String, pub monitored: bool, pub tags: Vec, - pub add_options: AddOptions, + pub add_options: AddMovieOptions, } #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] @@ -47,54 +51,11 @@ pub struct AddMovieSearchResult { #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct AddOptions { +pub struct AddMovieOptions { pub monitor: String, pub search_for_movie: bool, } -#[derive(Default, Serialize, Debug)] -pub struct AddRootFolderBody { - pub path: String, -} - -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum AuthenticationMethod { - #[default] - Basic, - Forms, - None, -} - -impl Display for AuthenticationMethod { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let authentication_method = match self { - AuthenticationMethod::Basic => "basic", - AuthenticationMethod::Forms => "forms", - AuthenticationMethod::None => "none", - }; - write!(f, "{authentication_method}") - } -} - -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum AuthenticationRequired { - Enabled, - #[default] - DisabledForLocalAddresses, -} - -impl Display for AuthenticationRequired { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let authentication_required = match self { - AuthenticationRequired::Enabled => "enabled", - AuthenticationRequired::DisabledForLocalAddresses => "disabledForLocalAddresses", - }; - write!(f, "{authentication_required}") - } -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub struct BlocklistResponse { pub records: Vec, @@ -123,26 +84,6 @@ pub struct BlocklistItemMovie { pub title: HorizontallyScrollableText, } -#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] -#[serde(rename_all = "camelCase")] -pub enum CertificateValidation { - #[default] - Enabled, - DisabledForLocalAddresses, - Disabled, -} - -impl Display for CertificateValidation { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { - let certificate_validation = match self { - CertificateValidation::Enabled => "enabled", - CertificateValidation::DisabledForLocalAddresses => "disabledForLocalAddresses", - CertificateValidation::Disabled => "disabled", - }; - write!(f, "{certificate_validation}") - } -} - #[derive(Serialize, Deserialize, Derivative, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Collection { @@ -175,12 +116,6 @@ pub struct CollectionMovie { pub ratings: RatingsList, } -#[derive(Default, Serialize, Debug)] -#[serde(rename_all = "camelCase")] -pub struct CommandBody { - pub name: String, -} - #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct Credit { @@ -208,15 +143,6 @@ pub struct DeleteMovieParams { pub add_list_exclusion: bool, } -#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct DiskSpace { - #[serde(deserialize_with = "super::from_i64")] - pub free_space: i64, - #[serde(deserialize_with = "super::from_i64")] - pub total_space: i64, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct DownloadRecord { @@ -253,22 +179,6 @@ pub struct EditCollectionParams { pub search_on_add: Option, } -#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct EditIndexerParams { - pub indexer_id: i64, - pub name: Option, - pub enable_rss: Option, - pub enable_automatic_search: Option, - pub enable_interactive_search: Option, - pub url: Option, - pub api_key: Option, - pub seed_ratio: Option, - pub tags: Option>, - pub priority: Option, - pub clear_tags: bool, -} - #[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditMovieParams { @@ -281,51 +191,6 @@ pub struct EditMovieParams { pub clear_tags: bool, } -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct HostConfig { - pub bind_address: HorizontallyScrollableText, - #[serde(deserialize_with = "super::from_i64")] - pub port: i64, - pub url_base: Option, - pub instance_name: Option, - pub application_url: Option, - pub enable_ssl: bool, - #[serde(deserialize_with = "super::from_i64")] - pub ssl_port: i64, - pub ssl_cert_path: Option, - pub ssl_cert_password: Option, -} - -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Indexer { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: Option, - pub implementation: Option, - pub implementation_name: Option, - pub config_contract: Option, - pub supports_rss: bool, - pub supports_search: bool, - pub fields: Option>, - pub enable_rss: bool, - pub enable_automatic_search: bool, - pub enable_interactive_search: bool, - pub protocol: String, - #[serde(deserialize_with = "super::from_i64")] - pub priority: i64, - #[serde(deserialize_with = "super::from_i64")] - pub download_client_id: i64, - pub tags: Vec, -} - -#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] -pub struct IndexerField { - pub name: Option, - pub value: Option, -} - #[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] #[serde(rename_all = "camelCase")] pub struct IndexerSettings { @@ -364,28 +229,6 @@ pub struct IndexerValidationFailure { pub severity: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Language { - pub name: String, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct Log { - pub time: DateTime, - pub exception: Option, - pub exception_type: Option, - pub level: String, - pub logger: Option, - pub message: Option, - pub method: Option, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] -pub struct LogResponse { - pub records: Vec, -} - #[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] #[serde(rename_all = "camelCase")] @@ -434,8 +277,8 @@ impl Display for MinimumAvailability { } } -impl MinimumAvailability { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for MinimumAvailability { + fn to_display_str(self) -> &'a str { match self { MinimumAvailability::Tba => "TBA", MinimumAvailability::Announced => "Announced", @@ -446,30 +289,30 @@ impl MinimumAvailability { } #[derive(Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum)] -pub enum Monitor { +pub enum MovieMonitor { #[default] MovieOnly, MovieAndCollection, None, } -impl Display for Monitor { +impl Display for MovieMonitor { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let monitor = match self { - Monitor::MovieOnly => "movieOnly", - Monitor::MovieAndCollection => "movieAndCollection", - Monitor::None => "none", + MovieMonitor::MovieOnly => "movieOnly", + MovieMonitor::MovieAndCollection => "movieAndCollection", + MovieMonitor::None => "none", }; write!(f, "{monitor}") } } -impl Monitor { - pub fn to_display_str<'a>(self) -> &'a str { +impl<'a> EnumDisplayStyle<'a> for MovieMonitor { + fn to_display_str(self) -> &'a str { match self { - Monitor::MovieOnly => "Movie only", - Monitor::MovieAndCollection => "Movie and Collection", - Monitor::None => "None", + MovieMonitor::MovieOnly => "Movie only", + MovieMonitor::MovieAndCollection => "Movie and Collection", + MovieMonitor::None => "None", } } } @@ -538,45 +381,6 @@ pub struct MovieHistoryItem { pub event_type: String, } -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct Quality { - pub name: String, -} - -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct QualityProfile { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub name: String, -} - -impl From<(&i64, &String)> for QualityProfile { - fn from(value: (&i64, &String)) -> Self { - QualityProfile { - id: *value.0, - name: value.1.clone(), - } - } -} - -#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] -pub struct QualityWrapper { - pub quality: Quality, -} - -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct QueueEvent { - pub trigger: String, - pub name: String, - pub command_name: String, - pub status: String, - pub queued: DateTime, - pub started: Option>, - pub ended: Option>, - pub duration: Option, -} - #[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[derivative(Default)] pub struct Rating { @@ -595,7 +399,7 @@ pub struct RatingsList { #[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] #[serde(default)] -pub struct Release { +pub struct RadarrRelease { pub guid: String, pub protocol: String, #[serde(deserialize_with = "super::from_i64")] @@ -616,35 +420,12 @@ pub struct Release { #[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] #[serde(rename_all = "camelCase")] -pub struct ReleaseDownloadBody { +pub struct RadarrReleaseDownloadBody { pub guid: String, pub indexer_id: i64, pub movie_id: i64, } -#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] -#[serde(rename_all = "camelCase")] -pub struct RootFolder { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub path: String, - pub accessible: bool, - #[serde(deserialize_with = "super::from_i64")] - pub free_space: i64, - pub unmapped_folders: Option>, -} - -#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct SecurityConfig { - pub authentication_method: AuthenticationMethod, - pub authentication_required: AuthenticationRequired, - pub username: String, - pub password: Option, - pub api_key: String, - pub certificate_validation: CertificateValidation, -} - #[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct SystemStatus { @@ -652,18 +433,11 @@ pub struct SystemStatus { pub start_time: DateTime, } -#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] -pub struct Tag { - #[serde(deserialize_with = "super::from_i64")] - pub id: i64, - pub label: String, -} - #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] #[serde(rename_all = "camelCase")] -pub struct Task { +pub struct RadarrTask { pub name: String, - pub task_name: TaskName, + pub task_name: RadarrTaskName, #[serde(deserialize_with = "super::from_i64")] pub interval: i64, pub last_execution: DateTime, @@ -673,7 +447,7 @@ pub struct Task { #[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] #[serde(rename_all = "PascalCase")] -pub enum TaskName { +pub enum RadarrTaskName { #[default] ApplicationCheckUpdate, Backup, @@ -688,7 +462,7 @@ pub enum TaskName { RssSync, } -impl Display for TaskName { +impl Display for RadarrTaskName { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { let task_name = serde_json::to_string(&self) .expect("Unable to serialize task name") @@ -697,30 +471,6 @@ impl Display for TaskName { } } -#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] -pub struct UnmappedFolder { - pub name: String, - pub path: String, -} - -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct Update { - pub version: String, - pub release_date: DateTime, - pub installed: bool, - pub latest: bool, - pub installed_on: Option>, - pub changes: UpdateChanges, -} - -#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] -#[serde(rename_all = "camelCase")] -pub struct UpdateChanges { - pub new: Option>, - pub fixed: Option>, -} - #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] #[serde(untagged)] #[allow(clippy::large_enum_variant)] @@ -741,12 +491,12 @@ pub enum RadarrSerdeable { Movies(Vec), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), - Tasks(Vec), + Tasks(Vec), Updates(Vec), AddMovieSearchResults(Vec), IndexerTestResults(Vec), @@ -782,12 +532,12 @@ serde_enum_from!( Movies(Vec), QualityProfiles(Vec), QueueEvents(Vec), - Releases(Vec), + Releases(Vec), RootFolders(Vec), SecurityConfig(SecurityConfig), SystemStatus(SystemStatus), Tags(Vec), - Tasks(Vec), + Tasks(Vec), Updates(Vec), AddMovieSearchResults(Vec), IndexerTestResults(Vec), diff --git a/src/models/radarr_models_tests.rs b/src/models/radarr_models_tests.rs index 4553f22..b260344 100644 --- a/src/models/radarr_models_tests.rs +++ b/src/models/radarr_models_tests.rs @@ -5,45 +5,19 @@ mod tests { use crate::models::{ radarr_models::{ - AddMovieSearchResult, AuthenticationMethod, AuthenticationRequired, BlocklistItem, - BlocklistResponse, CertificateValidation, Collection, Credit, DiskSpace, DownloadRecord, - DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse, - MinimumAvailability, Monitor, Movie, MovieHistoryItem, QualityProfile, QueueEvent, - RadarrSerdeable, Release, RootFolder, SystemStatus, Tag, Task, TaskName, Update, + AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace, + DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult, + MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease, + RadarrSerdeable, RadarrTask, RadarrTaskName, SystemStatus, Tag, Update, }, - Serdeable, + servarr_models::{HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig}, + EnumDisplayStyle, Serdeable, }; - #[test] - fn test_authentication_method_display() { - assert_str_eq!(AuthenticationMethod::Basic.to_string(), "basic"); - assert_str_eq!(AuthenticationMethod::Forms.to_string(), "forms"); - assert_str_eq!(AuthenticationMethod::None.to_string(), "none"); - } - - #[test] - fn test_authentication_required_display() { - assert_str_eq!(AuthenticationRequired::Enabled.to_string(), "enabled"); - assert_str_eq!( - AuthenticationRequired::DisabledForLocalAddresses.to_string(), - "disabledForLocalAddresses" - ); - } - - #[test] - fn test_certificate_validation_display() { - assert_str_eq!(CertificateValidation::Enabled.to_string(), "enabled"); - assert_str_eq!( - CertificateValidation::DisabledForLocalAddresses.to_string(), - "disabledForLocalAddresses" - ); - assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); - } - #[test] fn test_task_name_display() { assert_str_eq!( - TaskName::ApplicationCheckUpdate.to_string(), + RadarrTaskName::ApplicationCheckUpdate.to_string(), "ApplicationCheckUpdate" ); } @@ -69,22 +43,22 @@ mod tests { #[test] fn test_monitor_display() { - assert_str_eq!(Monitor::MovieOnly.to_string(), "movieOnly"); + assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly"); assert_str_eq!( - Monitor::MovieAndCollection.to_string(), + MovieMonitor::MovieAndCollection.to_string(), "movieAndCollection" ); - assert_str_eq!(Monitor::None.to_string(), "none"); + assert_str_eq!(MovieMonitor::None.to_string(), "none"); } #[test] fn test_monitor_to_display_str() { - assert_str_eq!(Monitor::MovieOnly.to_display_str(), "Movie only"); + assert_str_eq!(MovieMonitor::MovieOnly.to_display_str(), "Movie only"); assert_str_eq!( - Monitor::MovieAndCollection.to_display_str(), + MovieMonitor::MovieAndCollection.to_display_str(), "Movie and Collection" ); - assert_str_eq!(Monitor::None.to_display_str(), "None"); + assert_str_eq!(MovieMonitor::None.to_display_str(), "None"); } #[test] @@ -205,6 +179,18 @@ mod tests { assert_eq!(radarr_serdeable, RadarrSerdeable::DiskSpaces(disk_spaces)); } + #[test] + fn test_radarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 1234, + ..HostConfig::default() + }; + + let radarr_serdeable: RadarrSerdeable = host_config.clone().into(); + + assert_eq!(radarr_serdeable, RadarrSerdeable::HostConfig(host_config)); + } + #[test] fn test_radarr_serdeable_from_downloads_response() { let downloads_response = DownloadsResponse { @@ -331,9 +317,9 @@ mod tests { #[test] fn test_radarr_serdeable_from_releases() { - let releases = vec![Release { + let releases = vec![RadarrRelease { size: 1, - ..Release::default() + ..RadarrRelease::default() }]; let radarr_serdeable: RadarrSerdeable = releases.clone().into(); @@ -353,6 +339,21 @@ mod tests { assert_eq!(radarr_serdeable, RadarrSerdeable::RootFolders(root_folders)); } + #[test] + fn test_radarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + username: Some("Test".to_owned()), + ..SecurityConfig::default() + }; + + let radarr_serdeable: RadarrSerdeable = security_config.clone().into(); + + assert_eq!( + radarr_serdeable, + RadarrSerdeable::SecurityConfig(security_config) + ); + } + #[test] fn test_radarr_serdeable_from_system_status() { let system_status = SystemStatus { @@ -382,9 +383,9 @@ mod tests { #[test] fn test_radarr_serdeable_from_tasks() { - let tasks = vec![Task { + let tasks = vec![RadarrTask { name: "test".to_owned(), - ..Task::default() + ..RadarrTask::default() }]; let radarr_serdeable: RadarrSerdeable = tasks.clone().into(); diff --git a/src/models/servarr_data/mod.rs b/src/models/servarr_data/mod.rs index c430c1a..c82e844 100644 --- a/src/models/servarr_data/mod.rs +++ b/src/models/servarr_data/mod.rs @@ -1 +1,3 @@ +pub mod modals; pub mod radarr; +pub mod sonarr; diff --git a/src/models/servarr_data/modals.rs b/src/models/servarr_data/modals.rs new file mode 100644 index 0000000..0105249 --- /dev/null +++ b/src/models/servarr_data/modals.rs @@ -0,0 +1,20 @@ +use crate::models::HorizontallyScrollableText; + +#[derive(Default, Debug, PartialEq, Eq)] +pub struct EditIndexerModal { + pub name: HorizontallyScrollableText, + pub enable_rss: Option, + pub enable_automatic_search: Option, + pub enable_interactive_search: Option, + pub url: HorizontallyScrollableText, + pub api_key: HorizontallyScrollableText, + pub seed_ratio: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +#[derive(Default, Clone, Eq, PartialEq, Debug)] +pub struct IndexerTestResultModalItem { + pub name: String, + pub is_valid: bool, + pub validation_failures: HorizontallyScrollableText, +} diff --git a/src/models/servarr_data/radarr/modals.rs b/src/models/servarr_data/radarr/modals.rs index 074991c..1b9c0d9 100644 --- a/src/models/servarr_data/radarr/modals.rs +++ b/src/models/servarr_data/radarr/modals.rs @@ -1,10 +1,11 @@ use strum::IntoEnumIterator; use crate::models::radarr_models::{ - Collection, Credit, Indexer, MinimumAvailability, Monitor, Movie, MovieHistoryItem, Release, - RootFolder, + Collection, Credit, MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, RadarrRelease, }; +use crate::models::servarr_data::modals::EditIndexerModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; +use crate::models::servarr_models::{Indexer, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, ScrollableText}; @@ -22,19 +23,7 @@ pub struct MovieDetailsModal { pub movie_history: StatefulTable, pub movie_cast: StatefulTable, pub movie_crew: StatefulTable, - pub movie_releases: StatefulTable, -} - -#[derive(Default, Debug, PartialEq, Eq)] -pub struct EditIndexerModal { - pub name: HorizontallyScrollableText, - pub enable_rss: Option, - pub enable_automatic_search: Option, - pub enable_interactive_search: Option, - pub url: HorizontallyScrollableText, - pub api_key: HorizontallyScrollableText, - pub seed_ratio: HorizontallyScrollableText, - pub tags: HorizontallyScrollableText, + pub movie_releases: StatefulTable, } impl From<&RadarrData<'_>> for EditIndexerModal { @@ -195,7 +184,7 @@ impl From<&RadarrData<'_>> for EditMovieModal { #[derive(Default)] pub struct AddMovieModal { pub root_folder_list: StatefulList, - pub monitor_list: StatefulList, + pub monitor_list: StatefulList, pub minimum_availability_list: StatefulList, pub quality_profile_list: StatefulList, pub tags: HorizontallyScrollableText, @@ -206,7 +195,7 @@ impl From<&RadarrData<'_>> for AddMovieModal { let mut add_movie_modal = AddMovieModal::default(); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); @@ -291,10 +280,3 @@ impl From<&RadarrData<'_>> for EditCollectionModal { edit_collection_modal } } - -#[derive(Default, Clone, Eq, PartialEq, Debug)] -pub struct IndexerTestResultModalItem { - pub name: String, - pub is_valid: bool, - pub validation_failures: HorizontallyScrollableText, -} diff --git a/src/models/servarr_data/radarr/modals_tests.rs b/src/models/servarr_data/radarr/modals_tests.rs index 63479aa..e19d8a9 100644 --- a/src/models/servarr_data/radarr/modals_tests.rs +++ b/src/models/servarr_data/radarr/modals_tests.rs @@ -1,13 +1,12 @@ #[cfg(test)] mod test { - use crate::models::radarr_models::{ - Collection, Indexer, IndexerField, MinimumAvailability, Monitor, Movie, RootFolder, - }; + use crate::models::radarr_models::{Collection, MinimumAvailability, Movie, MovieMonitor}; use crate::models::servarr_data::radarr::modals::{ AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, }; use crate::models::servarr_data::radarr::radarr_data::radarr_test_utils::utils::create_test_radarr_data; use crate::models::servarr_data::radarr::radarr_data::RadarrData; + use crate::models::servarr_models::{Indexer, IndexerField, RootFolder}; use crate::models::stateful_table::StatefulTable; use bimap::BiMap; use pretty_assertions::{assert_eq, assert_str_eq}; @@ -184,7 +183,7 @@ mod test { assert_eq!( add_movie_modal.monitor_list.items, - Vec::from_iter(Monitor::iter()) + Vec::from_iter(MovieMonitor::iter()) ); assert_eq!( add_movie_modal.minimum_availability_list.items, diff --git a/src/models/servarr_data/radarr/radarr_data.rs b/src/models/servarr_data/radarr/radarr_data.rs index e422670..dcae501 100644 --- a/src/models/servarr_data/radarr/radarr_data.rs +++ b/src/models/servarr_data/radarr/radarr_data.rs @@ -6,13 +6,14 @@ use crate::app::radarr::radarr_context_clues::{ SYSTEM_CONTEXT_CLUES, }; use crate::models::radarr_models::{ - AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DiskSpace, DownloadRecord, - Indexer, IndexerSettings, Movie, QueueEvent, RootFolder, Task, + AddMovieSearchResult, BlocklistItem, Collection, CollectionMovie, DownloadRecord, + IndexerSettings, Movie, RadarrTask, }; +use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, - MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, }; +use crate::models::servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}; use crate::models::stateful_list::StatefulList; use crate::models::stateful_table::StatefulTable; use crate::models::{ @@ -47,7 +48,7 @@ pub struct RadarrData<'a> { pub collection_movies: StatefulTable, pub logs: StatefulList, pub log_details: StatefulList, - pub tasks: StatefulTable, + pub tasks: StatefulTable, pub queued_events: StatefulTable, pub updates: ScrollableText, pub main_tabs: TabState, diff --git a/src/models/servarr_data/radarr/radarr_data_tests.rs b/src/models/servarr_data/radarr/radarr_data_tests.rs index a487156..1c42d9f 100644 --- a/src/models/servarr_data/radarr/radarr_data_tests.rs +++ b/src/models/servarr_data/radarr/radarr_data_tests.rs @@ -19,6 +19,14 @@ mod tests { use crate::assert_movie_info_tabs_reset; use crate::models::BlockSelectionState; + #[test] + fn test_from_active_radarr_block_to_route() { + assert_eq!( + Route::from(ActiveRadarrBlock::AddMoviePrompt), + Route::Radarr(ActiveRadarrBlock::AddMoviePrompt, None) + ); + } + #[test] fn test_from_tuple_to_route_with_context() { assert_eq!( @@ -60,7 +68,7 @@ mod tests { assert_eq!(radarr_data.disk_space_vec, Vec::new()); assert!(radarr_data.version.is_empty()); assert_eq!(radarr_data.start_time, >::default()); - assert!(radarr_data.movies.items.is_empty()); + assert!(radarr_data.movies.is_empty()); assert_eq!(radarr_data.selected_block, BlockSelectionState::default()); assert!(radarr_data.downloads.items.is_empty()); assert!(radarr_data.indexers.items.is_empty()); diff --git a/src/models/servarr_data/radarr/radarr_test_utils.rs b/src/models/servarr_data/radarr/radarr_test_utils.rs index e20e530..a3c5469 100644 --- a/src/models/servarr_data/radarr/radarr_test_utils.rs +++ b/src/models/servarr_data/radarr/radarr_test_utils.rs @@ -1,7 +1,7 @@ #[cfg(test)] pub mod utils { use crate::models::radarr_models::{ - AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, Release, + AddMovieSearchResult, CollectionMovie, Credit, MovieHistoryItem, RadarrRelease, }; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::RadarrData; @@ -24,7 +24,7 @@ pub mod utils { .set_items(vec![Credit::default()]); movie_details_modal .movie_releases - .set_items(vec![Release::default()]); + .set_items(vec![RadarrRelease::default()]); let mut radarr_data = RadarrData { delete_movie_files: true, diff --git a/src/models/servarr_data/sonarr/mod.rs b/src/models/servarr_data/sonarr/mod.rs new file mode 100644 index 0000000..8058f64 --- /dev/null +++ b/src/models/servarr_data/sonarr/mod.rs @@ -0,0 +1,2 @@ +pub mod modals; +pub mod sonarr_data; diff --git a/src/models/servarr_data/sonarr/modals.rs b/src/models/servarr_data/sonarr/modals.rs new file mode 100644 index 0000000..c41b58b --- /dev/null +++ b/src/models/servarr_data/sonarr/modals.rs @@ -0,0 +1,267 @@ +use strum::IntoEnumIterator; + +use crate::models::{ + servarr_data::modals::EditIndexerModal, + servarr_models::{Indexer, RootFolder}, + sonarr_models::{Episode, Series, SeriesMonitor, SeriesType, SonarrHistoryItem, SonarrRelease}, + stateful_list::StatefulList, + stateful_table::StatefulTable, + HorizontallyScrollableText, ScrollableText, +}; + +use super::sonarr_data::SonarrData; + +#[cfg(test)] +#[path = "modals_tests.rs"] +mod modals_tests; + +#[derive(Default)] +pub struct AddSeriesModal { + pub root_folder_list: StatefulList, + pub monitor_list: StatefulList, + pub quality_profile_list: StatefulList, + pub language_profile_list: StatefulList, + pub series_type_list: StatefulList, + pub use_season_folder: bool, + pub tags: HorizontallyScrollableText, +} + +impl From<&SonarrData> for AddSeriesModal { + fn from(sonarr_data: &SonarrData) -> AddSeriesModal { + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + ..AddSeriesModal::default() + }; + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + let mut quality_profile_names: Vec = sonarr_data + .quality_profile_map + .right_values() + .cloned() + .collect(); + quality_profile_names.sort(); + add_series_modal + .quality_profile_list + .set_items(quality_profile_names); + let mut language_profile_names: Vec = sonarr_data + .language_profiles_map + .right_values() + .cloned() + .collect(); + language_profile_names.sort(); + add_series_modal + .language_profile_list + .set_items(language_profile_names); + add_series_modal + .root_folder_list + .set_items(sonarr_data.root_folders.items.to_vec()); + + add_series_modal + } +} + +impl From<&SonarrData> for EditIndexerModal { + fn from(sonarr_data: &SonarrData) -> EditIndexerModal { + let mut edit_indexer_modal = EditIndexerModal::default(); + let Indexer { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + tags, + fields, + .. + } = sonarr_data.indexers.current_selection(); + let seed_ratio_field_option = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "seedCriteria.seedRatio"); + let seed_ratio_value_option = if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field.value.clone() + } else { + None + }; + + edit_indexer_modal.name = name.clone().unwrap().into(); + edit_indexer_modal.enable_rss = Some(*enable_rss); + edit_indexer_modal.enable_automatic_search = Some(*enable_automatic_search); + edit_indexer_modal.enable_interactive_search = Some(*enable_interactive_search); + edit_indexer_modal.url = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "baseUrl") + .unwrap() + .value + .clone() + .unwrap() + .as_str() + .unwrap() + .into(); + edit_indexer_modal.api_key = fields + .as_ref() + .unwrap() + .iter() + .find(|field| field.name.as_ref().unwrap() == "apiKey") + .unwrap() + .value + .clone() + .unwrap() + .as_str() + .unwrap() + .into(); + + if seed_ratio_value_option.is_some() { + edit_indexer_modal.seed_ratio = seed_ratio_value_option + .unwrap() + .as_f64() + .unwrap() + .to_string() + .into(); + } + + edit_indexer_modal.tags = tags + .iter() + .map(|tag_id| { + sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + .into(); + + edit_indexer_modal + } +} + +#[derive(Default)] +pub struct EditSeriesModal { + pub series_type_list: StatefulList, + pub quality_profile_list: StatefulList, + pub language_profile_list: StatefulList, + pub monitored: Option, + pub use_season_folders: Option, + pub path: HorizontallyScrollableText, + pub tags: HorizontallyScrollableText, +} + +impl From<&SonarrData> for EditSeriesModal { + fn from(sonarr_data: &SonarrData) -> EditSeriesModal { + let mut edit_series_modal = EditSeriesModal::default(); + let Series { + path, + tags, + monitored, + season_folder, + series_type, + quality_profile_id, + language_profile_id, + .. + } = sonarr_data.series.current_selection(); + + edit_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + edit_series_modal.path = path.clone().into(); + edit_series_modal.tags = tags + .iter() + .map(|tag_id| { + sonarr_data + .tags_map + .get_by_left(&tag_id.as_i64().unwrap()) + .unwrap() + .clone() + }) + .collect::>() + .join(", ") + .into(); + + edit_series_modal.monitored = Some(*monitored); + edit_series_modal.use_season_folders = Some(*season_folder); + + let series_type_index = edit_series_modal + .series_type_list + .items + .iter() + .position(|st| st == series_type); + edit_series_modal + .series_type_list + .state + .select(series_type_index); + + let mut quality_profile_names: Vec = sonarr_data + .quality_profile_map + .right_values() + .cloned() + .collect(); + quality_profile_names.sort(); + edit_series_modal + .quality_profile_list + .set_items(quality_profile_names); + let quality_profile_name = sonarr_data + .quality_profile_map + .get_by_left(quality_profile_id) + .unwrap(); + let quality_profile_index = edit_series_modal + .quality_profile_list + .items + .iter() + .position(|profile| profile == quality_profile_name); + edit_series_modal + .quality_profile_list + .state + .select(quality_profile_index); + let mut language_profile_names: Vec = sonarr_data + .language_profiles_map + .right_values() + .cloned() + .collect(); + language_profile_names.sort(); + edit_series_modal + .language_profile_list + .set_items(language_profile_names); + let language_profile_name = sonarr_data + .language_profiles_map + .get_by_left(language_profile_id) + .unwrap(); + let language_profile_index = edit_series_modal + .language_profile_list + .items + .iter() + .position(|profile| profile == language_profile_name); + edit_series_modal + .language_profile_list + .state + .select(language_profile_index); + + edit_series_modal + } +} + +#[derive(Default)] +pub struct EpisodeDetailsModal { + // Temporarily allowing this, since the value is only current written and not read. + // This will be read from once I begin the UI work for Sonarr + #[allow(dead_code)] + pub episode_details: ScrollableText, + pub file_details: String, + pub audio_details: String, + pub video_details: String, + pub episode_history: StatefulTable, + pub episode_releases: StatefulTable, +} + +#[derive(Default)] +pub struct SeasonDetailsModal { + pub episodes: StatefulTable, + pub episode_details_modal: Option, + pub season_releases: StatefulTable, +} diff --git a/src/models/servarr_data/sonarr/modals_tests.rs b/src/models/servarr_data/sonarr/modals_tests.rs new file mode 100644 index 0000000..96af39f --- /dev/null +++ b/src/models/servarr_data/sonarr/modals_tests.rs @@ -0,0 +1,224 @@ +#[cfg(test)] +mod tests { + use bimap::BiMap; + use pretty_assertions::{assert_eq, assert_str_eq}; + use rstest::rstest; + use strum::IntoEnumIterator; + + use crate::models::servarr_data::sonarr::modals::EditSeriesModal; + use crate::models::servarr_models::{Indexer, IndexerField}; + use crate::models::{ + servarr_data::sonarr::{modals::AddSeriesModal, sonarr_data::SonarrData}, + servarr_models::RootFolder, + sonarr_models::{SeriesMonitor, SeriesType}, + }; + use crate::models::{sonarr_models::Series, stateful_table::StatefulTable}; + use serde_json::{Number, Value}; + + use crate::models::servarr_data::modals::EditIndexerModal; + + #[test] + fn test_add_series_modal_from_sonarr_data() { + let root_folder = RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }; + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + ..SonarrData::default() + }; + sonarr_data + .root_folders + .set_items(vec![root_folder.clone()]); + + let add_series_modal = AddSeriesModal::from(&sonarr_data); + + assert_eq!( + add_series_modal.monitor_list.items, + Vec::from_iter(SeriesMonitor::iter()) + ); + assert_eq!( + add_series_modal.series_type_list.items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + add_series_modal.quality_profile_list.items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_eq!( + add_series_modal.language_profile_list.items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_eq!(add_series_modal.root_folder_list.items, vec![root_folder]); + assert!(add_series_modal.tags.text.is_empty()); + assert!(add_series_modal.use_season_folder); + } + + #[rstest] + fn test_edit_indexer_modal_from_sonarr_data(#[values(true, false)] seed_ratio_present: bool) { + let mut sonarr_data = SonarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..SonarrData::default() + }; + let mut fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + ]; + + if seed_ratio_present { + fields.push(IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(Value::from(1.2f64)), + }); + } + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + sonarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&sonarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_eq!(edit_indexer_modal.enable_rss, Some(true)); + assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); + assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + + if seed_ratio_present { + assert_str_eq!(edit_indexer_modal.seed_ratio.text, "1.2"); + } else { + assert!(edit_indexer_modal.seed_ratio.text.is_empty()); + } + } + + #[test] + fn test_edit_indexer_modal_from_sonarr_data_seed_ratio_value_is_none() { + let mut sonarr_data = SonarrData { + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + ..SonarrData::default() + }; + let fields = vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(Value::String("https://test.com".to_owned())), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(Value::String("1234".to_owned())), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: None, + }, + ]; + + let indexer = Indexer { + name: Some("Test".to_owned()), + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + tags: vec![Number::from(1), Number::from(2)], + fields: Some(fields), + ..Indexer::default() + }; + sonarr_data.indexers.set_items(vec![indexer]); + + let edit_indexer_modal = EditIndexerModal::from(&sonarr_data); + + assert_str_eq!(edit_indexer_modal.name.text, "Test"); + assert_eq!(edit_indexer_modal.enable_rss, Some(true)); + assert_eq!(edit_indexer_modal.enable_automatic_search, Some(true)); + assert_eq!(edit_indexer_modal.enable_interactive_search, Some(true)); + assert_str_eq!(edit_indexer_modal.url.text, "https://test.com"); + assert_str_eq!(edit_indexer_modal.api_key.text, "1234"); + assert!(edit_indexer_modal.seed_ratio.text.is_empty()); + } + + #[rstest] + fn test_edit_series_modal_from_sonarr_data(#[values(true, false)] test_filtered_series: bool) { + let mut sonarr_data = SonarrData { + quality_profile_map: BiMap::from_iter([ + (2222, "HD - 1080p".to_owned()), + (1111, "Any".to_owned()), + ]), + language_profiles_map: BiMap::from_iter([ + (2222, "English".to_owned()), + (1111, "Any".to_owned()), + ]), + tags_map: BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]), + series: StatefulTable::default(), + ..SonarrData::default() + }; + let series = Series { + path: "/nfs/seriess/Test".to_owned(), + monitored: true, + season_folder: true, + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: SeriesType::Anime, + tags: vec![Number::from(1), Number::from(2)], + ..Series::default() + }; + + if test_filtered_series { + sonarr_data.series.set_filtered_items(vec![series]); + } else { + sonarr_data.series.set_items(vec![series]); + } + + let edit_series_modal = EditSeriesModal::from(&sonarr_data); + + assert_eq!( + edit_series_modal.series_type_list.items, + Vec::from_iter(SeriesType::iter()) + ); + assert_eq!( + edit_series_modal.series_type_list.current_selection(), + &SeriesType::Anime, + ); + assert_eq!( + edit_series_modal.quality_profile_list.items, + vec!["Any".to_owned(), "HD - 1080p".to_owned()] + ); + assert_str_eq!( + edit_series_modal.quality_profile_list.current_selection(), + "HD - 1080p" + ); + assert_eq!( + edit_series_modal.language_profile_list.items, + vec!["Any".to_owned(), "English".to_owned()] + ); + assert_str_eq!( + edit_series_modal.language_profile_list.current_selection(), + "English" + ); + assert_str_eq!(edit_series_modal.path.text, "/nfs/seriess/Test"); + assert_str_eq!(edit_series_modal.tags.text, "usenet, test"); + assert_eq!(edit_series_modal.monitored, Some(true)); + assert_eq!(edit_series_modal.use_season_folders, Some(true)); + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data.rs b/src/models/servarr_data/sonarr/sonarr_data.rs new file mode 100644 index 0000000..82b7198 --- /dev/null +++ b/src/models/servarr_data/sonarr/sonarr_data.rs @@ -0,0 +1,214 @@ +use bimap::BiMap; +use chrono::{DateTime, Utc}; +use strum::EnumIter; + +use crate::models::{ + servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}, + servarr_models::{DiskSpace, Indexer, QueueEvent, RootFolder}, + sonarr_models::{ + AddSeriesSearchResult, BlocklistItem, DownloadRecord, IndexerSettings, Season, Series, + SonarrHistoryItem, SonarrTask, + }, + stateful_list::StatefulList, + stateful_table::StatefulTable, + HorizontallyScrollableText, Route, ScrollableText, +}; + +use super::modals::{AddSeriesModal, EditSeriesModal, SeasonDetailsModal}; + +#[cfg(test)] +#[path = "sonarr_data_tests.rs"] +mod sonarr_data_tests; + +pub struct SonarrData { + pub add_list_exclusion: bool, + pub add_searched_series: Option>, + pub add_series_modal: Option, + pub add_series_search: Option, + pub blocklist: StatefulTable, + pub delete_series_files: bool, + pub downloads: StatefulTable, + pub disk_space_vec: Vec, + pub edit_indexer_modal: Option, + pub edit_root_folder: Option, + pub edit_series_modal: Option, + pub history: StatefulTable, + pub indexers: StatefulTable, + pub indexer_settings: Option, + pub indexer_test_all_results: Option>, + pub indexer_test_error: Option, + pub language_profiles_map: BiMap, + pub logs: StatefulList, + pub quality_profile_map: BiMap, + pub queued_events: StatefulTable, + pub root_folders: StatefulTable, + pub seasons: StatefulTable, + pub season_details_modal: Option, + pub series: StatefulTable, + pub series_history: Option>, + pub start_time: DateTime, + pub tags_map: BiMap, + pub tasks: StatefulTable, + pub updates: ScrollableText, + pub version: String, +} + +impl SonarrData { + pub fn reset_delete_series_preferences(&mut self) { + self.delete_series_files = false; + self.add_list_exclusion = false; + } +} + +impl Default for SonarrData { + fn default() -> SonarrData { + SonarrData { + add_list_exclusion: false, + add_searched_series: None, + add_series_search: None, + add_series_modal: None, + blocklist: StatefulTable::default(), + downloads: StatefulTable::default(), + delete_series_files: false, + disk_space_vec: Vec::new(), + edit_indexer_modal: None, + edit_root_folder: None, + edit_series_modal: None, + history: StatefulTable::default(), + indexers: StatefulTable::default(), + indexer_settings: None, + indexer_test_error: None, + indexer_test_all_results: None, + language_profiles_map: BiMap::new(), + logs: StatefulList::default(), + quality_profile_map: BiMap::new(), + queued_events: StatefulTable::default(), + root_folders: StatefulTable::default(), + seasons: StatefulTable::default(), + season_details_modal: None, + series: StatefulTable::default(), + series_history: None, + start_time: DateTime::default(), + tags_map: BiMap::default(), + tasks: StatefulTable::default(), + updates: ScrollableText::default(), + version: String::new(), + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, EnumIter)] +pub enum ActiveSonarrBlock { + AddRootFolderPrompt, + AddSeriesAlreadyInLibrary, + AddSeriesConfirmPrompt, + AddSeriesEmptySearchResults, + AddSeriesPrompt, + AddSeriesSearchInput, + AddSeriesSearchResults, + AddSeriesSelectLanguageProfile, + AddSeriesSelectMonitor, + AddSeriesSelectQualityProfile, + AddSeriesSelectRootFolder, + AddSeriesSelectSeriesType, + AddSeriesTagsInput, + AddSeriesToggleUseSeasonFolder, + AllIndexerSettingsPrompt, + AutomaticallySearchEpisodePrompt, + AutomaticallySearchSeasonPrompt, + AutomaticallySearchSeriesPrompt, + Blocklist, + BlocklistClearAllItemsPrompt, + BlocklistItemDetails, + BlocklistSortPrompt, + DeleteBlocklistItemPrompt, + DeleteDownloadPrompt, + DeleteEpisodeFilePrompt, + DeleteIndexerPrompt, + DeleteRootFolderPrompt, + DeleteSeriesConfirmPrompt, + DeleteSeriesPrompt, + DeleteSeriesToggleAddListExclusion, + DeleteSeriesToggleDeleteFile, + Downloads, + EditIndexerPrompt, + EditSeriesPrompt, + EditSeriesConfirmPrompt, + EditSeriesPathInput, + EditSeriesSelectSeriesType, + EditSeriesSelectQualityProfile, + EditSeriesSelectLanguageProfile, + EditSeriesTagsInput, + EditSeriesToggleMonitored, + EditSeriesToggleSeasonFolder, + EpisodeDetails, + EpisodeFile, + EpisodeHistory, + EpisodesSortPrompt, + FilterEpisodes, + FilterEpisodesError, + FilterHistory, + FilterHistoryError, + FilterSeries, + FilterSeriesError, + FilterSeriesHistory, + FilterSeriesHistoryError, + History, + HistoryDetails, + HistorySortPrompt, + Indexers, + IndexerSettingsConfirmPrompt, + IndexerSettingsMaximumSizeInput, + IndexerSettingsMinimumAgeInput, + IndexerSettingsRetentionInput, + IndexerSettingsRssSyncIntervalInput, + ManualEpisodeSearch, + ManualEpisodeSearchConfirmPrompt, + ManualEpisodeSearchSortPrompt, + ManualSeasonSearch, + ManualSeasonSearchConfirmPrompt, + ManualSeasonSearchSortPrompt, + MarkHistoryItemAsFailedConfirmPrompt, + MarkHistoryItemAsFailedPrompt, + RootFolders, + SearchEpisodes, + SearchEpisodesError, + SearchHistory, + SearchHistoryError, + SearchSeason, + SearchSeasonError, + SearchSeries, + SearchSeriesError, + SearchSeriesHistory, + SearchSeriesHistoryError, + SeasonDetails, + SeasonHistory, + #[default] + Series, + SeriesDetails, + SeriesHistory, + SeriesHistorySortPrompt, + SeriesSortPrompt, + System, + SystemLogs, + SystemQueuedEvents, + SystemTasks, + SystemTaskStartConfirmPrompt, + SystemUpdates, + TestAllIndexers, + TestIndexer, + UpdateAllSeriesPrompt, + UpdateAndScanSeriesPrompt, +} + +impl From for Route { + fn from(active_sonarr_block: ActiveSonarrBlock) -> Route { + Route::Sonarr(active_sonarr_block, None) + } +} + +impl From<(ActiveSonarrBlock, Option)> for Route { + fn from(value: (ActiveSonarrBlock, Option)) -> Route { + Route::Sonarr(value.0, value.1) + } +} diff --git a/src/models/servarr_data/sonarr/sonarr_data_tests.rs b/src/models/servarr_data/sonarr/sonarr_data_tests.rs new file mode 100644 index 0000000..de9b25a --- /dev/null +++ b/src/models/servarr_data/sonarr/sonarr_data_tests.rs @@ -0,0 +1,83 @@ +#[cfg(test)] +mod tests { + mod sonarr_data_tests { + use chrono::{DateTime, Utc}; + + use crate::models::{ + servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}, + Route, + }; + + #[test] + fn test_from_active_sonarr_block_to_route() { + assert_eq!( + Route::from(ActiveSonarrBlock::SeriesSortPrompt), + Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, None) + ); + } + + #[test] + fn test_from_tuple_to_route_with_context() { + assert_eq!( + Route::from(( + ActiveSonarrBlock::SeriesSortPrompt, + Some(ActiveSonarrBlock::Series) + )), + Route::Sonarr( + ActiveSonarrBlock::SeriesSortPrompt, + Some(ActiveSonarrBlock::Series), + ) + ); + } + + #[test] + fn test_reset_delete_series_preferences() { + let mut sonarr_data = SonarrData { + add_list_exclusion: true, + delete_series_files: true, + ..SonarrData::default() + }; + + sonarr_data.reset_delete_series_preferences(); + + assert!(!sonarr_data.delete_series_files); + assert!(!sonarr_data.add_list_exclusion); + } + + #[test] + fn test_sonarr_data_defaults() { + let sonarr_data = SonarrData::default(); + + assert!(!sonarr_data.add_list_exclusion); + assert!(sonarr_data.add_searched_series.is_none()); + assert!(sonarr_data.add_series_search.is_none()); + assert!(sonarr_data.add_series_modal.is_none()); + assert!(sonarr_data.blocklist.is_empty()); + assert!(!sonarr_data.delete_series_files); + assert!(sonarr_data.downloads.is_empty()); + assert!(sonarr_data.disk_space_vec.is_empty()); + assert!(sonarr_data.edit_indexer_modal.is_none()); + assert!(sonarr_data.edit_root_folder.is_none()); + assert!(sonarr_data.edit_series_modal.is_none()); + assert!(sonarr_data.history.is_empty()); + assert!(sonarr_data.indexers.is_empty()); + assert!(sonarr_data.indexer_settings.is_none()); + assert!(sonarr_data.indexer_test_error.is_none()); + assert!(sonarr_data.indexer_test_all_results.is_none()); + assert!(sonarr_data.language_profiles_map.is_empty()); + assert!(sonarr_data.logs.is_empty()); + assert!(sonarr_data.quality_profile_map.is_empty()); + assert!(sonarr_data.queued_events.is_empty()); + assert!(sonarr_data.root_folders.is_empty()); + assert!(sonarr_data.seasons.is_empty()); + assert!(sonarr_data.season_details_modal.is_none()); + assert!(sonarr_data.series.is_empty()); + assert!(sonarr_data.series_history.is_none()); + assert_eq!(sonarr_data.start_time, >::default()); + assert!(sonarr_data.tags_map.is_empty()); + assert!(sonarr_data.tasks.is_empty()); + assert!(sonarr_data.updates.is_empty()); + assert!(sonarr_data.version.is_empty()); + } + } +} diff --git a/src/models/servarr_models.rs b/src/models/servarr_models.rs new file mode 100644 index 0000000..4f1810b --- /dev/null +++ b/src/models/servarr_models.rs @@ -0,0 +1,271 @@ +use std::fmt::{Display, Formatter, Result}; + +use chrono::{DateTime, Utc}; +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use serde_json::{Number, Value}; + +use super::HorizontallyScrollableText; + +#[cfg(test)] +#[path = "servarr_models_tests.rs"] +mod servarr_models_tests; + +#[derive(Default, Serialize, Debug)] +pub struct AddRootFolderBody { + pub path: String, +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum AuthenticationMethod { + #[default] + Basic, + Forms, + None, +} + +impl Display for AuthenticationMethod { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let authentication_method = match self { + AuthenticationMethod::Basic => "basic", + AuthenticationMethod::Forms => "forms", + AuthenticationMethod::None => "none", + }; + write!(f, "{authentication_method}") + } +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum AuthenticationRequired { + Enabled, + #[default] + DisabledForLocalAddresses, +} + +impl Display for AuthenticationRequired { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let authentication_required = match self { + AuthenticationRequired::Enabled => "enabled", + AuthenticationRequired::DisabledForLocalAddresses => "disabledForLocalAddresses", + }; + write!(f, "{authentication_required}") + } +} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, ValueEnum)] +#[serde(rename_all = "camelCase")] +pub enum CertificateValidation { + #[default] + Enabled, + DisabledForLocalAddresses, + Disabled, +} + +impl Display for CertificateValidation { + fn fmt(&self, f: &mut Formatter<'_>) -> Result { + let certificate_validation = match self { + CertificateValidation::Enabled => "enabled", + CertificateValidation::DisabledForLocalAddresses => "disabledForLocalAddresses", + CertificateValidation::Disabled => "disabled", + }; + write!(f, "{certificate_validation}") + } +} + +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CommandBody { + pub name: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DiskSpace { + #[serde(deserialize_with = "super::from_i64")] + pub free_space: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_space: i64, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditIndexerParams { + pub indexer_id: i64, + pub name: Option, + pub enable_rss: Option, + pub enable_automatic_search: Option, + pub enable_interactive_search: Option, + pub url: Option, + pub api_key: Option, + pub seed_ratio: Option, + pub tags: Option>, + pub priority: Option, + pub clear_tags: bool, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HostConfig { + pub bind_address: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub port: i64, + pub url_base: Option, + pub instance_name: Option, + pub application_url: Option, + pub enable_ssl: bool, + #[serde(deserialize_with = "super::from_i64")] + pub ssl_port: i64, + pub ssl_cert_path: Option, + pub ssl_cert_password: Option, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Indexer { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: Option, + pub implementation: Option, + pub implementation_name: Option, + pub config_contract: Option, + pub supports_rss: bool, + pub supports_search: bool, + pub fields: Option>, + pub enable_rss: bool, + pub enable_automatic_search: bool, + pub enable_interactive_search: bool, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub priority: i64, + #[serde(deserialize_with = "super::from_i64")] + pub download_client_id: i64, + pub tags: Vec, +} + +#[derive(Default, Deserialize, Serialize, Debug, Clone, Eq, PartialEq)] +pub struct IndexerField { + pub name: Option, + pub value: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Language { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Log { + pub time: DateTime, + pub exception: Option, + pub exception_type: Option, + pub level: String, + pub logger: Option, + pub message: Option, + pub method: Option, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, Eq, PartialEq)] +pub struct LogResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct Quality { + pub name: String, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct QualityProfile { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub name: String, +} + +impl From<(&i64, &String)> for QualityProfile { + fn from(value: (&i64, &String)) -> Self { + QualityProfile { + id: *value.0, + name: value.1.clone(), + } + } +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq, Ord, PartialOrd)] +pub struct QualityWrapper { + pub quality: Quality, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct QueueEvent { + pub trigger: String, + pub name: String, + pub command_name: String, + pub status: String, + pub queued: DateTime, + pub started: Option>, + pub ended: Option>, + pub duration: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RootFolder { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub path: String, + pub accessible: bool, + #[serde(deserialize_with = "super::from_i64")] + pub free_space: i64, + pub unmapped_folders: Option>, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SecurityConfig { + pub authentication_method: AuthenticationMethod, + #[serde(skip_serializing_if = "Option::is_none")] + pub authentication_required: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub password: Option, + pub api_key: String, + pub certificate_validation: CertificateValidation, +} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct Tag { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub label: String, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, Eq, PartialEq)] +pub struct UnmappedFolder { + pub name: String, + pub path: String, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Update { + pub version: String, + pub release_date: DateTime, + pub installed: bool, + pub latest: bool, + pub installed_on: Option>, + pub changes: UpdateChanges, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct UpdateChanges { + pub new: Option>, + pub fixed: Option>, +} diff --git a/src/models/servarr_models_tests.rs b/src/models/servarr_models_tests.rs new file mode 100644 index 0000000..dfe4cc9 --- /dev/null +++ b/src/models/servarr_models_tests.rs @@ -0,0 +1,49 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + + use crate::models::servarr_models::{ + AuthenticationMethod, AuthenticationRequired, CertificateValidation, QualityProfile, + }; + + #[test] + fn test_authentication_method_display() { + assert_str_eq!(AuthenticationMethod::Basic.to_string(), "basic"); + assert_str_eq!(AuthenticationMethod::Forms.to_string(), "forms"); + assert_str_eq!(AuthenticationMethod::None.to_string(), "none"); + } + + #[test] + fn test_authentication_required_display() { + assert_str_eq!(AuthenticationRequired::Enabled.to_string(), "enabled"); + assert_str_eq!( + AuthenticationRequired::DisabledForLocalAddresses.to_string(), + "disabledForLocalAddresses" + ); + } + + #[test] + fn test_certificate_validation_display() { + assert_str_eq!(CertificateValidation::Enabled.to_string(), "enabled"); + assert_str_eq!( + CertificateValidation::DisabledForLocalAddresses.to_string(), + "disabledForLocalAddresses" + ); + assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled"); + } + + #[test] + fn test_quality_profile_from_tuple_ref() { + let id = 2; + let name = "Test".to_owned(); + let quality_profile_tuple = (&id, &name); + let expected_quality_profile = QualityProfile { + id: 2, + name: "Test".to_owned(), + }; + + let quality_profile = QualityProfile::from(quality_profile_tuple); + + assert_eq!(expected_quality_profile, quality_profile); + } +} diff --git a/src/models/sonarr_models.rs b/src/models/sonarr_models.rs new file mode 100644 index 0000000..d98d9a7 --- /dev/null +++ b/src/models/sonarr_models.rs @@ -0,0 +1,694 @@ +use std::fmt::{Display, Formatter}; + +use chrono::{DateTime, Utc}; +use clap::ValueEnum; +use derivative::Derivative; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Number, Value}; +use strum::EnumIter; + +use crate::serde_enum_from; + +use super::{ + radarr_models::IndexerTestResult, + servarr_models::{ + DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper, + QueueEvent, RootFolder, SecurityConfig, Tag, Update, + }, + EnumDisplayStyle, HorizontallyScrollableText, Serdeable, +}; + +#[cfg(test)] +#[path = "sonarr_models_tests.rs"] +mod sonarr_models_tests; + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesBody { + pub tvdb_id: i64, + pub title: String, + pub monitored: bool, + pub root_folder_path: String, + pub quality_profile_id: i64, + pub language_profile_id: i64, + pub series_type: String, + pub season_folder: bool, + pub tags: Vec, + pub add_options: AddSeriesOptions, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResult { + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + pub title: HorizontallyScrollableText, + pub status: Option, + pub ended: bool, + pub overview: Option, + pub genres: Vec, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub network: Option, + #[serde(deserialize_with = "super::from_i64")] + pub runtime: i64, + pub ratings: Option, + pub statistics: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesSearchResultStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub season_count: i64, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AddSeriesOptions { + pub monitor: String, + pub search_for_cutoff_unmet_episodes: bool, + pub search_for_missing_episodes: bool, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BlocklistItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub series_id: i64, + pub episode_ids: Vec, + pub source_title: String, + pub language: Language, + pub quality: QualityWrapper, + pub date: DateTime, + pub protocol: String, + pub indexer: String, + pub message: String, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct BlocklistResponse { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub struct DeleteSeriesParams { + pub id: i64, + pub delete_series_files: bool, + pub add_list_exclusion: bool, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadRecord { + pub title: String, + pub status: String, + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_id: i64, + #[serde(deserialize_with = "super::from_f64")] + pub size: f64, + #[serde(deserialize_with = "super::from_f64")] + pub sizeleft: f64, + pub output_path: Option, + #[serde(default)] + pub indexer: String, + pub download_client: String, +} + +impl Eq for DownloadRecord {} + +#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DownloadsResponse { + pub records: Vec, +} + +#[derive(Default, Clone, Serialize, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EditSeriesParams { + pub series_id: i64, + pub monitored: Option, + pub use_season_folders: Option, + pub quality_profile_id: Option, + pub language_profile_id: Option, + pub series_type: Option, + pub root_folder_path: Option, + pub tags: Option>, + pub clear_tags: bool, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Episode { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub series_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub season_number: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_number: i64, + pub title: Option, + pub air_date_utc: Option>, + pub overview: Option, + pub has_file: bool, + pub monitored: bool, + pub episode_file: Option, +} + +impl Display for Episode { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.title.as_ref().unwrap_or(&String::new())) + } +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct EpisodeFile { + pub relative_path: String, + pub path: String, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub language: Language, + pub date_added: DateTime, + pub media_info: Option, +} + +#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct IndexerSettings { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub minimum_age: i64, + #[serde(deserialize_with = "super::from_i64")] + pub retention: i64, + #[serde(deserialize_with = "super::from_i64")] + pub maximum_size: i64, + #[serde(deserialize_with = "super::from_i64")] + pub rss_sync_interval: i64, +} + +#[derive(Serialize, Deserialize, Derivative, Debug, Clone, PartialEq, Eq)] +#[derivative(Default)] +#[serde(rename_all = "camelCase")] +pub struct MediaInfo { + #[serde(deserialize_with = "super::from_i64")] + pub audio_bitrate: i64, + #[derivative(Default(value = "Number::from(0)"))] + pub audio_channels: Number, + pub audio_codec: Option, + pub audio_languages: Option, + #[serde(deserialize_with = "super::from_i64")] + pub audio_stream_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bit_depth: i64, + #[serde(deserialize_with = "super::from_i64")] + pub video_bitrate: i64, + pub video_codec: String, + #[derivative(Default(value = "Number::from(0)"))] + pub video_fps: Number, + pub resolution: String, + pub run_time: String, + pub scan_type: String, + pub subtitles: Option, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Clone, PartialEq)] +#[derivative(Default)] +pub struct Rating { + #[serde(deserialize_with = "super::from_i64")] + pub votes: i64, + #[serde(deserialize_with = "super::from_f64")] + pub value: f64, +} + +impl Eq for Rating {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Season { + #[serde(deserialize_with = "super::from_i64")] + pub season_number: i64, + pub monitored: bool, + pub statistics: SeasonStatistics, +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeasonStatistics { + pub next_airing: Option>, + pub previous_airing: Option>, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_episodes: f64, +} + +impl Eq for SeasonStatistics {} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Series { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub tvdb_id: i64, + pub title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub quality_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub language_profile_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub runtime: i64, + #[serde(deserialize_with = "super::from_i64")] + pub year: i64, + pub monitored: bool, + pub series_type: SeriesType, + pub path: String, + pub genres: Vec, + pub tags: Vec, + pub ratings: Rating, + pub ended: bool, + pub status: SeriesStatus, + pub overview: Option, + pub network: Option, + pub season_folder: bool, + pub certification: Option, + pub statistics: Option, + pub seasons: Option>, +} + +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum, +)] +#[serde(rename_all = "camelCase")] +pub enum SeriesMonitor { + #[default] + All, + Unknown, + Future, + Missing, + Existing, + FirstSeason, + LastSeason, + LatestSeason, + Pilot, + Recent, + MonitorSpecials, + UnmonitorSpecials, + None, + Skip, +} + +impl Display for SeriesMonitor { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_monitor = match self { + SeriesMonitor::Unknown => "unknown", + SeriesMonitor::All => "all", + SeriesMonitor::Future => "future", + SeriesMonitor::Missing => "missing", + SeriesMonitor::Existing => "existing", + SeriesMonitor::FirstSeason => "firstSeason", + SeriesMonitor::LastSeason => "lastSeason", + SeriesMonitor::LatestSeason => "latestSeason", + SeriesMonitor::Pilot => "pilot", + SeriesMonitor::Recent => "recent", + SeriesMonitor::MonitorSpecials => "monitorSpecials", + SeriesMonitor::UnmonitorSpecials => "unmonitorSpecials", + SeriesMonitor::None => "none", + SeriesMonitor::Skip => "skip", + }; + write!(f, "{series_monitor}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SeriesMonitor { + fn to_display_str(self) -> &'a str { + match self { + SeriesMonitor::Unknown => "Unknown", + SeriesMonitor::All => "All Episodes", + SeriesMonitor::Future => "Future Episodes", + SeriesMonitor::Missing => "Missing Episodes", + SeriesMonitor::Existing => "Existing Episodes", + SeriesMonitor::FirstSeason => "Only First Season", + SeriesMonitor::LastSeason => "Only Last Season", + SeriesMonitor::LatestSeason => "Only Latest Season", + SeriesMonitor::Pilot => "Pilot Episode", + SeriesMonitor::Recent => "Recent Episodes", + SeriesMonitor::MonitorSpecials => "Only Specials", + SeriesMonitor::UnmonitorSpecials => "Not Specials", + SeriesMonitor::None => "None", + SeriesMonitor::Skip => "Skip", + } + } +} + +#[derive( + Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter, ValueEnum, +)] +#[serde(rename_all = "camelCase")] +pub enum SeriesType { + #[default] + Standard, + Daily, + Anime, +} + +impl Display for SeriesType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_type = match self { + SeriesType::Standard => "standard", + SeriesType::Daily => "daily", + SeriesType::Anime => "anime", + }; + write!(f, "{series_type}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SeriesType { + fn to_display_str(self) -> &'a str { + match self { + SeriesType::Standard => "Standard", + SeriesType::Daily => "Daily", + SeriesType::Anime => "Anime", + } + } +} + +#[derive(Derivative, Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SeriesStatistics { + #[serde(deserialize_with = "super::from_i64")] + pub season_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_file_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub total_episode_count: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size_on_disk: i64, + #[serde(deserialize_with = "super::from_f64")] + pub percent_of_episodes: f64, +} + +impl Eq for SeriesStatistics {} + +#[derive(Serialize, Deserialize, Default, PartialEq, Eq, Clone, Copy, Debug, EnumIter)] +#[serde(rename_all = "camelCase")] +pub enum SeriesStatus { + #[default] + Continuing, + Ended, + Upcoming, + Deleted, +} + +impl Display for SeriesStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let series_status = match self { + SeriesStatus::Continuing => "continuing", + SeriesStatus::Ended => "ended", + SeriesStatus::Upcoming => "upcoming", + SeriesStatus::Deleted => "deleted", + }; + write!(f, "{series_status}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SeriesStatus { + fn to_display_str(self) -> &'a str { + match self { + SeriesStatus::Continuing => "Continuing", + SeriesStatus::Ended => "Ended", + SeriesStatus::Upcoming => "Upcoming", + SeriesStatus::Deleted => "Deleted", + } + } +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrHistoryWrapper { + pub records: Vec, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrHistoryData { + pub dropped_path: Option, + pub imported_path: Option, + pub indexer: Option, + pub release_group: Option, + pub series_match_type: Option, + pub nzb_info_url: Option, + pub download_client_name: Option, + pub age: Option, + pub published_date: Option>, + pub message: Option, + pub reason: Option, +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SonarrHistoryEventType { + #[default] + Unknown, + Grabbed, + SeriesFolderImported, + DownloadFolderImported, + DownloadFailed, + EpisodeFileDeleted, + EpisodeFileRenamed, + DownloadIgnored, +} + +impl Display for SonarrHistoryEventType { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let event_type = match self { + SonarrHistoryEventType::Unknown => "unknown", + SonarrHistoryEventType::Grabbed => "grabbed", + SonarrHistoryEventType::SeriesFolderImported => "seriesFolderImported", + SonarrHistoryEventType::DownloadFolderImported => "downloadFolderImported", + SonarrHistoryEventType::DownloadFailed => "downloadFailed", + SonarrHistoryEventType::EpisodeFileDeleted => "episodeFileDeleted", + SonarrHistoryEventType::EpisodeFileRenamed => "episodeFileRenamed", + SonarrHistoryEventType::DownloadIgnored => "downloadIgnored", + }; + write!(f, "{event_type}") + } +} + +impl<'a> EnumDisplayStyle<'a> for SonarrHistoryEventType { + fn to_display_str(self) -> &'a str { + match self { + SonarrHistoryEventType::Unknown => "Unknown", + SonarrHistoryEventType::Grabbed => "Grabbed", + SonarrHistoryEventType::SeriesFolderImported => "Series Folder Imported", + SonarrHistoryEventType::DownloadFolderImported => "Download Folder Imported", + SonarrHistoryEventType::DownloadFailed => "Download Failed", + SonarrHistoryEventType::EpisodeFileDeleted => "Episode File Deleted", + SonarrHistoryEventType::EpisodeFileRenamed => "Episode File Renamed", + SonarrHistoryEventType::DownloadIgnored => "Download Ignored", + } + } +} + +#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrHistoryItem { + #[serde(deserialize_with = "super::from_i64")] + pub id: i64, + pub source_title: HorizontallyScrollableText, + #[serde(deserialize_with = "super::from_i64")] + pub episode_id: i64, + pub quality: QualityWrapper, + pub language: Language, + pub date: DateTime, + pub event_type: String, + pub data: SonarrHistoryData, +} + +#[derive(Default, Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct SonarrCommandBody { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub season_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub episode_ids: Option>, +} + +#[derive(Serialize, Deserialize, Default, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[serde(default)] +pub struct SonarrRelease { + pub guid: String, + pub protocol: String, + #[serde(deserialize_with = "super::from_i64")] + pub age: i64, + pub title: HorizontallyScrollableText, + pub indexer: String, + #[serde(deserialize_with = "super::from_i64")] + pub indexer_id: i64, + #[serde(deserialize_with = "super::from_i64")] + pub size: i64, + pub rejected: bool, + pub rejections: Option>, + pub seeders: Option, + pub leechers: Option, + pub languages: Option>, + pub quality: QualityWrapper, + pub full_season: bool, +} +#[derive(Default, Serialize, Debug, PartialEq, Eq, Clone)] +#[serde(rename_all = "camelCase")] +pub struct SonarrReleaseDownloadBody { + pub guid: String, + pub indexer_id: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub series_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub episode_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub season_number: Option, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SonarrTask { + pub name: String, + pub task_name: SonarrTaskName, + #[serde(deserialize_with = "super::from_i64")] + pub interval: i64, + pub last_execution: DateTime, + pub next_execution: DateTime, +} + +#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Copy, ValueEnum)] +#[serde(rename_all = "PascalCase")] +pub enum SonarrTaskName { + #[default] + ApplicationUpdateCheck, + Backup, + CheckHealth, + CleanUpRecycleBin, + Housekeeping, + ImportListSync, + MessagingCleanup, + RefreshMonitoredDownloads, + RefreshSeries, + RssSync, + UpdateSceneMapping, +} + +impl Display for SonarrTaskName { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let task_name = serde_json::to_string(&self) + .expect("Unable to serialize task name") + .replace('"', ""); + write!(f, "{task_name}") + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +#[serde(untagged)] +#[allow(clippy::large_enum_variant)] +pub enum SonarrSerdeable { + AddSeriesSearchResults(Vec), + BlocklistResponse(BlocklistResponse), + DownloadsResponse(DownloadsResponse), + DiskSpaces(Vec), + Episode(Episode), + Episodes(Vec), + HostConfig(HostConfig), + IndexerSettings(IndexerSettings), + Indexers(Vec), + IndexerTestResults(Vec), + LanguageProfiles(Vec), + LogResponse(LogResponse), + QualityProfiles(Vec), + QueueEvents(Vec), + Releases(Vec), + RootFolders(Vec), + SecurityConfig(SecurityConfig), + SeriesVec(Vec), + Series(Series), + SonarrHistoryItems(Vec), + SonarrHistoryWrapper(SonarrHistoryWrapper), + SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), + Tasks(Vec), + Updates(Vec), + Value(Value), +} + +impl From for Serdeable { + fn from(value: SonarrSerdeable) -> Serdeable { + Serdeable::Sonarr(value) + } +} + +impl From<()> for SonarrSerdeable { + fn from(_: ()) -> Self { + SonarrSerdeable::Value(json!({})) + } +} + +serde_enum_from!( + SonarrSerdeable { + AddSeriesSearchResults(Vec), + BlocklistResponse(BlocklistResponse), + DownloadsResponse(DownloadsResponse), + DiskSpaces(Vec), + Episode(Episode), + Episodes(Vec), + HostConfig(HostConfig), + IndexerSettings(IndexerSettings), + Indexers(Vec), + IndexerTestResults(Vec), + LanguageProfiles(Vec), + LogResponse(LogResponse), + QualityProfiles(Vec), + QueueEvents(Vec), + Releases(Vec), + RootFolders(Vec), + SecurityConfig(SecurityConfig), + SeriesVec(Vec), + Series(Series), + SonarrHistoryItems(Vec), + SonarrHistoryWrapper(SonarrHistoryWrapper), + SystemStatus(SystemStatus), + Tag(Tag), + Tags(Vec), + Tasks(Vec), + Updates(Vec), + Value(Value), + } +); + +#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SystemStatus { + pub version: String, + pub start_time: DateTime, +} diff --git a/src/models/sonarr_models_tests.rs b/src/models/sonarr_models_tests.rs new file mode 100644 index 0000000..87ac76d --- /dev/null +++ b/src/models/sonarr_models_tests.rs @@ -0,0 +1,554 @@ +#[cfg(test)] +mod tests { + use pretty_assertions::{assert_eq, assert_str_eq}; + use serde_json::json; + + use crate::models::{ + radarr_models::IndexerTestResult, + servarr_models::{ + DiskSpace, HostConfig, Indexer, Language, Log, LogResponse, QualityProfile, QueueEvent, + RootFolder, SecurityConfig, Tag, Update, + }, + sonarr_models::{ + AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse, + Episode, IndexerSettings, Series, SeriesMonitor, SeriesStatus, SeriesType, + SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask, + SonarrTaskName, SystemStatus, + }, + EnumDisplayStyle, Serdeable, + }; + + #[test] + fn test_episode_display() { + let episode = Episode { + title: Some("Test Title".to_owned()), + ..Episode::default() + }; + + assert_str_eq!(Episode::default().to_string(), ""); + assert_str_eq!(episode.to_string(), "Test Title"); + } + + #[test] + fn test_series_monitor_display() { + assert_str_eq!(SeriesMonitor::Unknown.to_string(), "unknown"); + assert_str_eq!(SeriesMonitor::All.to_string(), "all"); + assert_str_eq!(SeriesMonitor::Future.to_string(), "future"); + assert_str_eq!(SeriesMonitor::Missing.to_string(), "missing"); + assert_str_eq!(SeriesMonitor::Existing.to_string(), "existing"); + assert_str_eq!(SeriesMonitor::FirstSeason.to_string(), "firstSeason"); + assert_str_eq!(SeriesMonitor::LastSeason.to_string(), "lastSeason"); + assert_str_eq!(SeriesMonitor::LatestSeason.to_string(), "latestSeason"); + assert_str_eq!(SeriesMonitor::Pilot.to_string(), "pilot"); + assert_str_eq!(SeriesMonitor::Recent.to_string(), "recent"); + assert_str_eq!( + SeriesMonitor::MonitorSpecials.to_string(), + "monitorSpecials" + ); + assert_str_eq!( + SeriesMonitor::UnmonitorSpecials.to_string(), + "unmonitorSpecials" + ); + assert_str_eq!(SeriesMonitor::None.to_string(), "none"); + assert_str_eq!(SeriesMonitor::Skip.to_string(), "skip"); + } + + #[test] + fn test_series_monitor_to_display_str() { + assert_str_eq!(SeriesMonitor::Unknown.to_display_str(), "Unknown"); + assert_str_eq!(SeriesMonitor::All.to_display_str(), "All Episodes"); + assert_str_eq!(SeriesMonitor::Future.to_display_str(), "Future Episodes"); + assert_str_eq!(SeriesMonitor::Missing.to_display_str(), "Missing Episodes"); + assert_str_eq!( + SeriesMonitor::Existing.to_display_str(), + "Existing Episodes" + ); + assert_str_eq!( + SeriesMonitor::FirstSeason.to_display_str(), + "Only First Season" + ); + assert_str_eq!( + SeriesMonitor::LastSeason.to_display_str(), + "Only Last Season" + ); + assert_str_eq!( + SeriesMonitor::LatestSeason.to_display_str(), + "Only Latest Season" + ); + assert_str_eq!(SeriesMonitor::Pilot.to_display_str(), "Pilot Episode"); + assert_str_eq!(SeriesMonitor::Recent.to_display_str(), "Recent Episodes"); + assert_str_eq!( + SeriesMonitor::MonitorSpecials.to_display_str(), + "Only Specials" + ); + assert_str_eq!( + SeriesMonitor::UnmonitorSpecials.to_display_str(), + "Not Specials" + ); + assert_str_eq!(SeriesMonitor::None.to_display_str(), "None"); + assert_str_eq!(SeriesMonitor::Skip.to_display_str(), "Skip"); + } + + #[test] + fn test_series_status_display() { + assert_str_eq!(SeriesStatus::Continuing.to_string(), "continuing"); + assert_str_eq!(SeriesStatus::Ended.to_string(), "ended"); + assert_str_eq!(SeriesStatus::Upcoming.to_string(), "upcoming"); + assert_str_eq!(SeriesStatus::Deleted.to_string(), "deleted"); + } + + #[test] + fn test_series_status_to_display_str() { + assert_str_eq!(SeriesStatus::Continuing.to_display_str(), "Continuing"); + assert_str_eq!(SeriesStatus::Ended.to_display_str(), "Ended"); + assert_str_eq!(SeriesStatus::Upcoming.to_display_str(), "Upcoming"); + assert_str_eq!(SeriesStatus::Deleted.to_display_str(), "Deleted"); + } + + #[test] + fn test_series_type_display() { + assert_str_eq!(SeriesType::Standard.to_string(), "standard"); + assert_str_eq!(SeriesType::Daily.to_string(), "daily"); + assert_str_eq!(SeriesType::Anime.to_string(), "anime"); + } + + #[test] + fn test_series_type_to_display_str() { + assert_str_eq!(SeriesType::Standard.to_display_str(), "Standard"); + assert_str_eq!(SeriesType::Daily.to_display_str(), "Daily"); + assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime"); + } + + #[test] + fn test_sonarr_history_event_type_display() { + assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",); + assert_str_eq!(SonarrHistoryEventType::Grabbed.to_string(), "grabbed",); + assert_str_eq!( + SonarrHistoryEventType::SeriesFolderImported.to_string(), + "seriesFolderImported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFolderImported.to_string(), + "downloadFolderImported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFailed.to_string(), + "downloadFailed", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileDeleted.to_string(), + "episodeFileDeleted", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileRenamed.to_string(), + "episodeFileRenamed", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadIgnored.to_string(), + "downloadIgnored", + ); + } + + #[test] + fn test_sonarr_history_event_type_to_display_str() { + assert_str_eq!(SonarrHistoryEventType::Unknown.to_display_str(), "Unknown",); + assert_str_eq!(SonarrHistoryEventType::Grabbed.to_display_str(), "Grabbed",); + assert_str_eq!( + SonarrHistoryEventType::SeriesFolderImported.to_display_str(), + "Series Folder Imported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFolderImported.to_display_str(), + "Download Folder Imported", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadFailed.to_display_str(), + "Download Failed", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileDeleted.to_display_str(), + "Episode File Deleted", + ); + assert_str_eq!( + SonarrHistoryEventType::EpisodeFileRenamed.to_display_str(), + "Episode File Renamed", + ); + assert_str_eq!( + SonarrHistoryEventType::DownloadIgnored.to_display_str(), + "Download Ignored", + ); + } + + #[test] + fn test_task_name_display() { + assert_str_eq!( + SonarrTaskName::ApplicationUpdateCheck.to_string(), + "ApplicationUpdateCheck" + ); + } + + #[test] + fn test_sonarr_serdeable_from() { + let sonarr_serdeable = SonarrSerdeable::Value(json!({})); + + let serdeable: Serdeable = Serdeable::from(sonarr_serdeable.clone()); + + assert_eq!(serdeable, Serdeable::Sonarr(sonarr_serdeable)); + } + + #[test] + fn test_sonarr_serdeable_from_unit() { + let sonarr_serdeable = SonarrSerdeable::from(()); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(json!({}))); + } + + #[test] + fn test_sonarr_serdeable_from_value() { + let value = json!({"test": "test"}); + + let sonarr_serdeable: SonarrSerdeable = value.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Value(value)); + } + + #[test] + fn test_sonarr_serdeable_from_episode() { + let episode = Episode { + id: 1, + ..Episode::default() + }; + + let sonarr_serdeable: SonarrSerdeable = episode.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Episode(episode)); + } + + #[test] + fn test_sonarr_serdeable_from_episodes() { + let episodes = vec![Episode { + id: 1, + ..Episode::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = episodes.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Episodes(episodes)); + } + + #[test] + fn test_sonarr_serdeable_from_host_config() { + let host_config = HostConfig { + port: 1234, + ..HostConfig::default() + }; + + let sonarr_serdeable: SonarrSerdeable = host_config.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::HostConfig(host_config)); + } + + #[test] + fn test_sonarr_serdeable_from_indexers() { + let indexers = vec![Indexer { + id: 1, + ..Indexer::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = indexers.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Indexers(indexers)); + } + + #[test] + fn test_sonarr_serdeable_from_indexer_settings() { + let indexer_settings = IndexerSettings { + id: 1, + ..IndexerSettings::default() + }; + + let sonarr_serdeable: SonarrSerdeable = indexer_settings.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::IndexerSettings(indexer_settings) + ); + } + + #[test] + fn test_sonarr_serdeable_from_series_vec() { + let series_vec = vec![Series { + id: 1, + ..Series::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = series_vec.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::SeriesVec(series_vec)); + } + + #[test] + fn test_sonarr_serdeable_from_series() { + let series = Series { + id: 1, + ..Series::default() + }; + + let sonarr_serdeable: SonarrSerdeable = series.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Series(series)); + } + + #[test] + fn test_sonarr_serdeable_from_sonarr_history_items() { + let history_items = vec![SonarrHistoryItem { + id: 1, + ..SonarrHistoryItem::default() + }]; + let sonarr_serdeable: SonarrSerdeable = history_items.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SonarrHistoryItems(history_items) + ); + } + + #[test] + fn test_sonarr_serdeable_from_system_status() { + let system_status = SystemStatus { + version: "1".to_owned(), + ..SystemStatus::default() + }; + + let sonarr_serdeable: SonarrSerdeable = system_status.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SystemStatus(system_status) + ); + } + + #[test] + fn test_sonarr_serdeable_from_add_series_search_results() { + let add_series_search_results = vec![AddSeriesSearchResult { + tvdb_id: 1, + ..AddSeriesSearchResult::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = add_series_search_results.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) + ); + } + + #[test] + fn test_sonarr_serdeable_from_blocklist_response() { + let blocklist_response = BlocklistResponse { + records: vec![BlocklistItem { + id: 1, + ..BlocklistItem::default() + }], + }; + + let sonarr_serdeable: SonarrSerdeable = blocklist_response.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::BlocklistResponse(blocklist_response) + ); + } + + #[test] + fn test_sonarr_serdeable_from_downloads_response() { + let downloads_response = DownloadsResponse { + records: vec![DownloadRecord { + id: 1, + ..DownloadRecord::default() + }], + }; + let sonarr_serdeable: SonarrSerdeable = downloads_response.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::DownloadsResponse(downloads_response) + ); + } + + #[test] + fn test_sonarr_serdeable_from_disk_spaces() { + let disk_spaces = vec![DiskSpace { + free_space: 1, + total_space: 1, + }]; + + let sonarr_serdeable: SonarrSerdeable = disk_spaces.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::DiskSpaces(disk_spaces)); + } + + #[test] + fn test_sonarr_serdeable_from_language_profiles() { + let language_profiles = vec![ + Language { + id: 1, + name: "English".to_owned(), + }, + Language { + id: 2, + name: "Japanese".to_owned(), + }, + ]; + + let sonarr_serdeable: SonarrSerdeable = language_profiles.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::LanguageProfiles(language_profiles) + ); + } + + #[test] + fn test_sonarr_serdeable_from_log_response() { + let log_response = LogResponse { + records: vec![Log { + level: "info".to_owned(), + ..Log::default() + }], + }; + + let sonarr_serdeable: SonarrSerdeable = log_response.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::LogResponse(log_response)); + } + + #[test] + fn test_sonarr_serdeable_from_quality_profiles() { + let quality_profiles = vec![QualityProfile { + name: "Test Profile".to_owned(), + id: 1, + }]; + + let sonarr_serdeable: SonarrSerdeable = quality_profiles.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::QualityProfiles(quality_profiles) + ); + } + + #[test] + fn test_sonarr_serdeable_from_queue_events() { + let queue_events = vec![QueueEvent { + trigger: "test".to_owned(), + ..QueueEvent::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = queue_events.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::QueueEvents(queue_events)); + } + + #[test] + fn test_sonarr_serdeable_from_releases() { + let releases = vec![SonarrRelease { + size: 1, + ..SonarrRelease::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = releases.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Releases(releases)); + } + + #[test] + fn test_sonarr_serdeable_from_root_folders() { + let root_folders = vec![RootFolder { + id: 1, + ..RootFolder::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = root_folders.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::RootFolders(root_folders)); + } + + #[test] + fn test_sonarr_serdeable_from_security_config() { + let security_config = SecurityConfig { + username: Some("Test".to_owned()), + ..SecurityConfig::default() + }; + + let sonarr_serdeable: SonarrSerdeable = security_config.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::SecurityConfig(security_config) + ); + } + + #[test] + fn test_sonarr_serdeable_from_tag() { + let tag = Tag { + id: 1, + ..Tag::default() + }; + + let sonarr_serdeable: SonarrSerdeable = tag.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tag(tag)); + } + + #[test] + fn test_sonarr_serdeable_from_tags() { + let tags = vec![Tag { + id: 1, + ..Tag::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = tags.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tags(tags)); + } + + #[test] + fn test_sonarr_serdeable_from_tasks() { + let tasks = vec![SonarrTask { + name: "test".to_owned(), + ..SonarrTask::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = tasks.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Tasks(tasks)); + } + + #[test] + fn test_sonarr_serdeable_from_updates() { + let updates = vec![Update { + version: "test".to_owned(), + ..Update::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = updates.clone().into(); + + assert_eq!(sonarr_serdeable, SonarrSerdeable::Updates(updates)); + } + + #[test] + fn test_sonarr_serdeable_from_indexer_test_results() { + let indexer_test_results = vec![IndexerTestResult { + id: 1, + ..IndexerTestResult::default() + }]; + + let sonarr_serdeable: SonarrSerdeable = indexer_test_results.clone().into(); + + assert_eq!( + sonarr_serdeable, + SonarrSerdeable::IndexerTestResults(indexer_test_results) + ); + } +} diff --git a/src/models/stateful_tree.rs b/src/models/stateful_tree.rs new file mode 100644 index 0000000..bc1904a --- /dev/null +++ b/src/models/stateful_tree.rs @@ -0,0 +1,68 @@ +use managarr_tree_widget::{TreeItem, TreeState}; +use ratatui::text::ToText; + +use super::Scrollable; +use core::hash::Hash; +use std::fmt::{Debug, Display}; + +#[cfg(test)] +#[path = "stateful_tree_tests.rs"] +mod stateful_tree_tests; + +#[derive(Default)] +pub struct StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, +{ + pub state: TreeState, + // Allowing the existence of this struct for now, since it may become useful + // for future UI developments with additional Servarrs + #[allow(dead_code)] + pub items: Vec>, +} + +// Allowing the existence of this struct for now, since it may become useful +// for future UI developments with additional Servarrs +#[allow(dead_code)] +impl StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display + PartialEq + Eq, +{ + pub fn set_items(&mut self, items: Vec>) { + self.items = items; + } + + pub fn current_selection(&self) -> Option<&T> { + self + .state + .flatten(&self.items) + .into_iter() + .find(|i| self.state.selected() == i.identifier) + .map(|item| item.item.content()) + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } +} + +impl Scrollable for StatefulTree +where + T: ToText + Hash + Clone + PartialEq + Eq + Debug + Default + Display, +{ + fn scroll_down(&mut self) { + self.state.key_down(); + } + + fn scroll_up(&mut self) { + self.state.key_up(); + } + + fn scroll_to_top(&mut self) { + self.state.select_first(); + } + + fn scroll_to_bottom(&mut self) { + self.state.select_last(); + } +} diff --git a/src/models/stateful_tree_tests.rs b/src/models/stateful_tree_tests.rs new file mode 100644 index 0000000..dcedf07 --- /dev/null +++ b/src/models/stateful_tree_tests.rs @@ -0,0 +1,177 @@ +#[cfg(test)] +mod tests { + use std::hash::{DefaultHasher, Hash, Hasher}; + + use crate::models::stateful_tree::StatefulTree; + use crate::models::Scrollable; + use managarr_tree_widget::{Tree, TreeItem, TreeState}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; + use ratatui::widgets::StatefulWidget; + + #[test] + fn test_stateful_tree_scrolling_on_empty_tree_performs_no_op() { + let mut stateful_tree: StatefulTree<&str> = StatefulTree::default(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_to_top(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), Vec::::new()); + + stateful_tree.scroll_to_bottom(); + render(&mut stateful_tree.state, &stateful_tree.items); + } + + #[test] + fn test_stateful_tree_scroll() { + let mut stateful_tree = create_test_stateful_tree(); + let hash = |s: &str| { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() + }; + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.scroll_up(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.scroll_to_bottom(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + + stateful_tree.scroll_to_top(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + } + + #[test] + fn test_stateful_tree_set_items() { + let items_vec = vec![ + TreeItem::new_leaf("Test 1"), + TreeItem::new_leaf("Test 2"), + TreeItem::new_leaf("Test 3"), + ]; + let hash = |s: &str| { + let mut hasher = DefaultHasher::new(); + s.hash(&mut hasher); + hasher.finish() + }; + let mut stateful_tree: StatefulTree<&str> = StatefulTree::default(); + + stateful_tree.set_items(items_vec.clone()); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 1")]); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.set_items(items_vec.clone()); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 2")]); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.set_items(items_vec); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert_eq!(stateful_tree.state.selected(), &[hash("Test 3")]); + } + + #[test] + fn test_stateful_tree_current_selection() { + let mut stateful_tree = create_test_stateful_tree(); + render(&mut stateful_tree.state, &stateful_tree.items); + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + + let current_selection = stateful_tree.current_selection(); + + assert!(current_selection.is_some()); + assert_str_eq!(current_selection.unwrap(), stateful_tree.items[0].content()); + + stateful_tree.state.key_down(); + render(&mut stateful_tree.state, &stateful_tree.items); + let current_selection = stateful_tree.current_selection(); + + assert!(current_selection.is_some()); + assert_str_eq!(current_selection.unwrap(), stateful_tree.items[1].content()); + } + + #[test] + fn test_stateful_tree_is_empty() { + let mut stateful_tree = create_test_stateful_tree(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert!(!stateful_tree.is_empty()); + + stateful_tree = StatefulTree::default(); + render(&mut stateful_tree.state, &stateful_tree.items); + + assert!(stateful_tree.is_empty()); + } + + fn render(state: &mut TreeState, items: &[TreeItem<&str>]) { + let tree = Tree::new(items).unwrap(); + let area = Rect::new(0, 0, 10, 4); + let mut buffer = Buffer::empty(area); + StatefulWidget::render(tree, area, &mut buffer, state); + } + + fn create_test_stateful_tree() -> StatefulTree<&'static str> { + let mut stateful_tree = StatefulTree::default(); + stateful_tree.set_items(vec![ + TreeItem::new_leaf("Test 1"), + TreeItem::new_leaf("Test 2"), + TreeItem::new_leaf("Test 3"), + ]); + + stateful_tree + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs index e3c252e..92b4f98 100644 --- a/src/network/mod.rs +++ b/src/network/mod.rs @@ -8,35 +8,42 @@ use regex::Regex; use reqwest::{Client, RequestBuilder}; use serde::de::DeserializeOwned; use serde::Serialize; +use sonarr_network::SonarrEvent; use strum_macros::Display; use tokio::select; use tokio::sync::{Mutex, MutexGuard}; use tokio_util::sync::CancellationToken; -use crate::app::App; +use crate::app::{App, ServarrConfig}; use crate::models::Serdeable; use crate::network::radarr_network::RadarrEvent; #[cfg(test)] use mockall::automock; pub mod radarr_network; +pub mod sonarr_network; mod utils; #[cfg(test)] #[path = "network_tests.rs"] mod network_tests; -#[derive(PartialEq, Eq, Debug, Clone)] -pub enum NetworkEvent { - Radarr(RadarrEvent), -} - #[cfg_attr(test, automock)] #[async_trait] pub trait NetworkTrait { async fn handle_network_event(&mut self, network_event: NetworkEvent) -> Result; } +pub trait NetworkResource { + fn resource(&self) -> &'static str; +} + +#[derive(PartialEq, Eq, Debug, Clone)] +pub enum NetworkEvent { + Radarr(RadarrEvent), + Sonarr(SonarrEvent), +} + #[derive(Clone)] pub struct Network<'a, 'b> { client: Client, @@ -52,6 +59,10 @@ impl<'a, 'b> NetworkTrait for Network<'a, 'b> { .handle_radarr_event(radarr_event) .await .map(Serdeable::from), + NetworkEvent::Sonarr(sonarr_event) => self + .handle_sonarr_event(sonarr_event) + .await + .map(Serdeable::from), }; let mut app = self.app.lock().await; @@ -180,6 +191,71 @@ impl<'a, 'b> Network<'a, 'b> { .header("X-Api-Key", api_token), } } + + async fn request_props_from( + &self, + network_event: N, + method: RequestMethod, + body: Option, + path: Option, + query_params: Option, + ) -> RequestProps + where + T: Serialize + Debug, + N: Into + NetworkResource, + { + let app = self.app.lock().await; + let resource = network_event.resource(); + let ( + ServarrConfig { + host, + port, + uri, + api_token, + ssl_cert_path, + }, + default_port, + ) = match network_event.into() { + NetworkEvent::Radarr(_) => ( + &app.config.radarr.as_ref().expect("Radarr config undefined"), + 7878, + ), + NetworkEvent::Sonarr(_) => ( + &app.config.sonarr.as_ref().expect("Sonarr config undefined"), + 8989, + ), + }; + let mut uri = if let Some(servarr_uri) = uri { + format!("{servarr_uri}/api/v3{resource}") + } else { + let protocol = if ssl_cert_path.is_some() { + "https" + } else { + "http" + }; + let host = host.as_ref().unwrap(); + format!( + "{protocol}://{host}:{}/api/v3{resource}", + port.unwrap_or(default_port) + ) + }; + + if let Some(path) = path { + uri = format!("{uri}{path}"); + } + + if let Some(params) = query_params { + uri = format!("{uri}?{params}"); + } + + RequestProps { + uri, + method, + body, + api_token: api_token.to_owned(), + ignore_status_code: false, + } + } } #[derive(Clone, Copy, Debug, Display, PartialEq, Eq)] diff --git a/src/network/network_tests.rs b/src/network/network_tests.rs index 5528a0f..1613ca0 100644 --- a/src/network/network_tests.rs +++ b/src/network/network_tests.rs @@ -12,9 +12,11 @@ mod tests { use tokio::sync::{mpsc, Mutex}; use tokio_util::sync::CancellationToken; - use crate::app::{App, AppConfig, RadarrConfig}; + use crate::app::{App, AppConfig, ServarrConfig}; use crate::models::HorizontallyScrollableText; use crate::network::radarr_network::RadarrEvent; + use crate::network::sonarr_network::SonarrEvent; + use crate::network::NetworkResource; use crate::network::{Network, NetworkEvent, NetworkTrait, RequestMethod, RequestProps}; #[tokio::test] @@ -34,14 +36,14 @@ mod tests { ); let mut app = App::default(); app.is_loading = true; - let radarr_config = RadarrConfig { + let radarr_config = ServarrConfig { host, api_token: String::new(), port, ssl_cert_path: None, - ..RadarrConfig::default() + ..ServarrConfig::default() }; - app.config.radarr = radarr_config; + app.config.radarr = Some(radarr_config); let app_arc = Arc::new(Mutex::new(app)); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -395,6 +397,256 @@ mod tests { async_server.assert_async().await; } + #[tokio::test] + #[should_panic(expected = "Radarr config undefined")] + async fn test_request_props_from_requires_radarr_config_to_be_present_for_radarr_events() { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .request_props_from( + RadarrEvent::GetMovies, + RequestMethod::Get, + None::<()>, + None, + None, + ) + .await; + } + + #[tokio::test] + #[should_panic(expected = "Sonarr config undefined")] + async fn test_request_props_from_requires_sonarr_config_to_be_present_for_sonarr_events() { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .request_props_from( + SonarrEvent::ListSeries, + RequestMethod::Get, + None::<()>, + None, + None, + ) + .await; + } + + #[rstest] + #[case(RadarrEvent::GetMovies, 7878)] + #[case(SonarrEvent::ListSeries, 8989)] + #[tokio::test] + async fn test_request_props_from_default_config( + #[case] network_event: impl Into + NetworkResource, + #[case] default_port: u16, + ) { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let resource = network_event.resource(); + app_arc.lock().await.config = AppConfig { + radarr: Some(ServarrConfig::default()), + sonarr: Some(ServarrConfig::default()), + }; + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("http://localhost:{default_port}/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert!(request_props.api_token.is_empty()); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + host: Some("192.168.0.123".to_owned()), + port: Some(8080), + api_token: api_token.clone(), + ssl_cert_path: Some("/test/cert.crt".to_owned()), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + uri: Some("https://192.168.0.123:8080".to_owned()), + api_token: api_token.clone(), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from(network_event, RequestMethod::Get, None::<()>, None, None) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[case(RadarrEvent::GetMovies, 7878)] + #[case(SonarrEvent::ListSeries, 8989)] + #[tokio::test] + async fn test_request_props_from_default_config_with_path_and_query_params( + #[case] network_event: impl Into + NetworkResource, + #[case] default_port: u16, + ) { + let app_arc = Arc::new(Mutex::new(App::default())); + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let resource = network_event.resource(); + app_arc.lock().await.config = AppConfig { + radarr: Some(ServarrConfig::default()), + sonarr: Some(ServarrConfig::default()), + }; + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("http://localhost:{default_port}/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert!(request_props.api_token.is_empty()); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_with_path_and_query_params( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + host: Some("192.168.0.123".to_owned()), + port: Some(8080), + api_token: api_token.clone(), + ssl_cert_path: Some("/test/cert.crt".to_owned()), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + + #[rstest] + #[tokio::test] + async fn test_request_props_from_custom_config_using_uri_instead_of_host_and_port_with_path_and_query_params( + #[values(RadarrEvent::GetMovies, SonarrEvent::ListSeries)] network_event: impl Into + + NetworkResource, + ) { + let api_token = "testToken1234".to_owned(); + let app_arc = Arc::new(Mutex::new(App::default())); + let resource = network_event.resource(); + let servarr_config = ServarrConfig { + uri: Some("https://192.168.0.123:8080".to_owned()), + api_token: api_token.clone(), + ..ServarrConfig::default() + }; + { + let mut app = app_arc.lock().await; + app.config.radarr = Some(servarr_config.clone()); + app.config.sonarr = Some(servarr_config); + } + let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let request_props = network + .request_props_from( + network_event, + RequestMethod::Get, + None::<()>, + Some("/test".to_owned()), + Some("id=1".to_owned()), + ) + .await; + + assert_str_eq!( + request_props.uri, + format!("https://192.168.0.123:8080/api/v3{resource}/test?id=1") + ); + assert_eq!(request_props.method, RequestMethod::Get); + assert_eq!(request_props.body, None); + assert_str_eq!(request_props.api_token, api_token); + } + #[derive(Clone, Serialize, Deserialize, Debug, Default, PartialEq, Eq)] struct Test { pub value: String, @@ -425,3 +677,78 @@ mod tests { (async_server, app_arc, server) } } + +#[cfg(test)] +pub(in crate::network) mod test_utils { + use std::sync::Arc; + + use mockito::{Matcher, Mock, Server, ServerGuard}; + use serde_json::Value; + use tokio::sync::Mutex; + + use crate::{ + app::{App, ServarrConfig}, + network::{NetworkEvent, NetworkResource, RequestMethod}, + }; + + pub async fn mock_servarr_api<'a>( + method: RequestMethod, + request_body: Option, + response_body: Option, + response_status: Option, + network_event: impl Into + NetworkResource, + path: Option<&str>, + query_params: Option<&str>, + ) -> (Mock, Arc>>, ServerGuard) { + let status = response_status.unwrap_or(200); + let resource = network_event.resource(); + let mut server = Server::new_async().await; + let mut uri = format!("/api/v3{resource}"); + + if let Some(path) = path { + uri = format!("{uri}{path}"); + } + + if let Some(params) = query_params { + uri = format!("{uri}?{params}"); + } + + let mut async_server = server + .mock(&method.to_string().to_uppercase(), uri.as_str()) + .match_header("X-Api-Key", "test1234") + .with_status(status); + + if let Some(body) = request_body { + async_server = async_server.match_body(Matcher::Json(body)); + } + + if let Some(body) = response_body { + async_server = async_server.with_body(body.to_string()); + } + + async_server = async_server.create_async().await; + + let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); + let port = Some( + server.host_with_port().split(':').collect::>()[1] + .parse() + .unwrap(), + ); + let mut app = App::default(); + let servarr_config = ServarrConfig { + host, + port, + api_token: "test1234".to_owned(), + ..ServarrConfig::default() + }; + + match network_event.into() { + NetworkEvent::Radarr(_) => app.config.radarr = Some(servarr_config), + NetworkEvent::Sonarr(_) => app.config.sonarr = Some(servarr_config), + } + + let app_arc = Arc::new(Mutex::new(app)); + + (async_server, app_arc, server) + } +} diff --git a/src/network/radarr_network.rs b/src/network/radarr_network.rs index 078dae0..0c5a8c3 100644 --- a/src/network/radarr_network.rs +++ b/src/network/radarr_network.rs @@ -3,29 +3,32 @@ use std::fmt::Debug; use indoc::formatdoc; use log::{debug, info, warn}; -use serde::Serialize; use serde_json::{json, Value}; use urlencoding::encode; -use crate::app::RadarrConfig; use crate::models::radarr_models::{ - AddMovieBody, AddMovieSearchResult, AddOptions, AddRootFolderBody, BlocklistResponse, Collection, - CollectionMovie, CommandBody, Credit, CreditType, DeleteMovieParams, DiskSpace, DownloadRecord, - DownloadsResponse, EditCollectionParams, EditIndexerParams, EditMovieParams, HostConfig, Indexer, - IndexerSettings, IndexerTestResult, LogResponse, Movie, MovieCommandBody, MovieHistoryItem, - QualityProfile, QueueEvent, RadarrSerdeable, Release, ReleaseDownloadBody, RootFolder, - SecurityConfig, SystemStatus, Tag, Task, TaskName, Update, + AddMovieBody, AddMovieOptions, AddMovieSearchResult, BlocklistResponse, Collection, + CollectionMovie, Credit, CreditType, DeleteMovieParams, DownloadRecord, DownloadsResponse, + EditCollectionParams, EditMovieParams, IndexerSettings, IndexerTestResult, Movie, + MovieCommandBody, MovieHistoryItem, RadarrRelease, RadarrReleaseDownloadBody, RadarrSerdeable, + RadarrTask, RadarrTaskName, SystemStatus, }; +use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; use crate::models::servarr_data::radarr::modals::{ - AddMovieModal, EditCollectionModal, EditIndexerModal, EditMovieModal, IndexerTestResultModalItem, - MovieDetailsModal, + AddMovieModal, EditCollectionModal, EditMovieModal, MovieDetailsModal, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::{ + AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, LogResponse, + QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, +}; use crate::models::stateful_table::StatefulTable; use crate::models::{HorizontallyScrollableText, Route, Scrollable, ScrollableText}; -use crate::network::{Network, NetworkEvent, RequestMethod, RequestProps}; +use crate::network::{Network, NetworkEvent, RequestMethod}; use crate::utils::{convert_runtime, convert_to_gb}; +use super::NetworkResource; + #[cfg(test)] #[path = "radarr_network_tests.rs"] mod radarr_network_tests; @@ -42,7 +45,7 @@ pub enum RadarrEvent { DeleteMovie(Option), DeleteRootFolder(Option), DeleteTag(i64), - DownloadRelease(Option), + DownloadRelease(Option), EditAllIndexerSettings(Option), EditCollection(Option), EditIndexer(Option), @@ -58,7 +61,7 @@ pub enum RadarrEvent { GetMovieDetails(Option), GetMovieHistory(Option), GetMovies, - GetOverview, + GetDiskSpace, GetQualityProfiles, GetQueuedEvents, GetReleases(Option), @@ -70,7 +73,7 @@ pub enum RadarrEvent { GetUpdates, HealthCheck, SearchNewMovie(Option), - StartTask(Option), + StartTask(Option), TestIndexer(Option), TestAllIndexers, TriggerAutomaticSearch(Option), @@ -80,8 +83,8 @@ pub enum RadarrEvent { UpdateDownloads, } -impl RadarrEvent { - const fn resource(&self) -> &'static str { +impl NetworkResource for RadarrEvent { + fn resource(&self) -> &'static str { match &self { RadarrEvent::ClearBlocklist => "/blocklist/bulk", RadarrEvent::DeleteBlocklistItem(_) => "/blocklist", @@ -104,7 +107,7 @@ impl RadarrEvent { RadarrEvent::SearchNewMovie(_) => "/movie/lookup", RadarrEvent::GetMovieCredits(_) => "/credit", RadarrEvent::GetMovieHistory(_) => "/history/movie", - RadarrEvent::GetOverview => "/diskspace", + RadarrEvent::GetDiskSpace => "/diskspace", RadarrEvent::GetQualityProfiles => "/qualityprofile", RadarrEvent::GetReleases(_) | RadarrEvent::DownloadRelease(_) => "/release", RadarrEvent::AddRootFolder(_) @@ -141,57 +144,71 @@ impl<'a, 'b> Network<'a, 'b> { ) -> Result { match radarr_event { RadarrEvent::AddMovie(body) => self.add_movie(body).await.map(RadarrSerdeable::from), - RadarrEvent::AddRootFolder(path) => { - self.add_root_folder(path).await.map(RadarrSerdeable::from) - } - RadarrEvent::AddTag(tag) => self.add_tag(tag).await.map(RadarrSerdeable::from), - RadarrEvent::ClearBlocklist => self.clear_blocklist().await.map(RadarrSerdeable::from), + RadarrEvent::AddRootFolder(path) => self + .add_radarr_root_folder(path) + .await + .map(RadarrSerdeable::from), + RadarrEvent::AddTag(tag) => self.add_radarr_tag(tag).await.map(RadarrSerdeable::from), + RadarrEvent::ClearBlocklist => self + .clear_radarr_blocklist() + .await + .map(RadarrSerdeable::from), RadarrEvent::DeleteBlocklistItem(blocklist_item_id) => self - .delete_blocklist_item(blocklist_item_id) + .delete_radarr_blocklist_item(blocklist_item_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteDownload(download_id) => self - .delete_download(download_id) + .delete_radarr_download(download_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteIndexer(indexer_id) => self - .delete_indexer(indexer_id) + .delete_radarr_indexer(indexer_id) .await .map(RadarrSerdeable::from), RadarrEvent::DeleteMovie(params) => { self.delete_movie(params).await.map(RadarrSerdeable::from) } RadarrEvent::DeleteRootFolder(root_folder_id) => self - .delete_root_folder(root_folder_id) + .delete_radarr_root_folder(root_folder_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::DeleteTag(tag_id) => self + .delete_radarr_tag(tag_id) .await .map(RadarrSerdeable::from), - RadarrEvent::DeleteTag(tag_id) => self.delete_tag(tag_id).await.map(RadarrSerdeable::from), RadarrEvent::DownloadRelease(params) => self - .download_release(params) + .download_radarr_release(params) .await .map(RadarrSerdeable::from), RadarrEvent::EditAllIndexerSettings(params) => self - .edit_all_indexer_settings(params) + .edit_all_radarr_indexer_settings(params) .await .map(RadarrSerdeable::from), RadarrEvent::EditCollection(params) => self .edit_collection(params) .await .map(RadarrSerdeable::from), - RadarrEvent::EditIndexer(params) => { - self.edit_indexer(params).await.map(RadarrSerdeable::from) - } - RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), - RadarrEvent::GetAllIndexerSettings => self - .get_all_indexer_settings() + RadarrEvent::EditIndexer(params) => self + .edit_radarr_indexer(params) .await .map(RadarrSerdeable::from), - RadarrEvent::GetBlocklist => self.get_blocklist().await.map(RadarrSerdeable::from), + RadarrEvent::EditMovie(params) => self.edit_movie(params).await.map(RadarrSerdeable::from), + RadarrEvent::GetAllIndexerSettings => self + .get_all_radarr_indexer_settings() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetBlocklist => self.get_radarr_blocklist().await.map(RadarrSerdeable::from), RadarrEvent::GetCollections => self.get_collections().await.map(RadarrSerdeable::from), - RadarrEvent::GetDownloads => self.get_downloads().await.map(RadarrSerdeable::from), - RadarrEvent::GetHostConfig => self.get_host_config().await.map(RadarrSerdeable::from), - RadarrEvent::GetIndexers => self.get_indexers().await.map(RadarrSerdeable::from), - RadarrEvent::GetLogs(events) => self.get_logs(events).await.map(RadarrSerdeable::from), + RadarrEvent::GetDownloads => self.get_radarr_downloads().await.map(RadarrSerdeable::from), + RadarrEvent::GetHostConfig => self + .get_radarr_host_config() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetIndexers => self.get_radarr_indexers().await.map(RadarrSerdeable::from), + RadarrEvent::GetLogs(events) => self + .get_radarr_logs(events) + .await + .map(RadarrSerdeable::from), RadarrEvent::GetMovieCredits(movie_id) => { self.get_credits(movie_id).await.map(RadarrSerdeable::from) } @@ -204,48 +221,70 @@ impl<'a, 'b> Network<'a, 'b> { .await .map(RadarrSerdeable::from), RadarrEvent::GetMovies => self.get_movies().await.map(RadarrSerdeable::from), - RadarrEvent::GetOverview => self.get_diskspace().await.map(RadarrSerdeable::from), - RadarrEvent::GetQualityProfiles => { - self.get_quality_profiles().await.map(RadarrSerdeable::from) - } - RadarrEvent::GetQueuedEvents => self.get_queued_events().await.map(RadarrSerdeable::from), - RadarrEvent::GetReleases(movie_id) => { - self.get_releases(movie_id).await.map(RadarrSerdeable::from) - } - RadarrEvent::GetRootFolders => self.get_root_folders().await.map(RadarrSerdeable::from), - RadarrEvent::GetSecurityConfig => self.get_security_config().await.map(RadarrSerdeable::from), - RadarrEvent::GetStatus => self.get_status().await.map(RadarrSerdeable::from), - RadarrEvent::GetTags => self.get_tags().await.map(RadarrSerdeable::from), - RadarrEvent::GetTasks => self.get_tasks().await.map(RadarrSerdeable::from), - RadarrEvent::GetUpdates => self.get_updates().await.map(RadarrSerdeable::from), - RadarrEvent::HealthCheck => self.get_healthcheck().await.map(RadarrSerdeable::from), + RadarrEvent::GetDiskSpace => self.get_radarr_diskspace().await.map(RadarrSerdeable::from), + RadarrEvent::GetQualityProfiles => self + .get_radarr_quality_profiles() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetQueuedEvents => self + .get_queued_radarr_events() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetReleases(movie_id) => self + .get_movie_releases(movie_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetRootFolders => self + .get_radarr_root_folders() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetSecurityConfig => self + .get_radarr_security_config() + .await + .map(RadarrSerdeable::from), + RadarrEvent::GetStatus => self.get_radarr_status().await.map(RadarrSerdeable::from), + RadarrEvent::GetTags => self.get_radarr_tags().await.map(RadarrSerdeable::from), + RadarrEvent::GetTasks => self.get_radarr_tasks().await.map(RadarrSerdeable::from), + RadarrEvent::GetUpdates => self.get_radarr_updates().await.map(RadarrSerdeable::from), + RadarrEvent::HealthCheck => self + .get_radarr_healthcheck() + .await + .map(RadarrSerdeable::from), RadarrEvent::SearchNewMovie(query) => { self.search_movie(query).await.map(RadarrSerdeable::from) } - RadarrEvent::StartTask(task_name) => { - self.start_task(task_name).await.map(RadarrSerdeable::from) - } - RadarrEvent::TestIndexer(indexer_id) => self - .test_indexer(indexer_id) + RadarrEvent::StartTask(task_name) => self + .start_radarr_task(task_name) + .await + .map(RadarrSerdeable::from), + RadarrEvent::TestIndexer(indexer_id) => self + .test_radarr_indexer(indexer_id) + .await + .map(RadarrSerdeable::from), + RadarrEvent::TestAllIndexers => self + .test_all_radarr_indexers() .await .map(RadarrSerdeable::from), - RadarrEvent::TestAllIndexers => self.test_all_indexers().await.map(RadarrSerdeable::from), RadarrEvent::TriggerAutomaticSearch(movie_id) => self - .trigger_automatic_search(movie_id) + .trigger_automatic_movie_search(movie_id) .await .map(RadarrSerdeable::from), RadarrEvent::UpdateAllMovies => self.update_all_movies().await.map(RadarrSerdeable::from), RadarrEvent::UpdateAndScan(movie_id) => self - .update_and_scan(movie_id) + .update_and_scan_movie(movie_id) .await .map(RadarrSerdeable::from), RadarrEvent::UpdateCollections => self.update_collections().await.map(RadarrSerdeable::from), - RadarrEvent::UpdateDownloads => self.update_downloads().await.map(RadarrSerdeable::from), + RadarrEvent::UpdateDownloads => self + .update_radarr_downloads() + .await + .map(RadarrSerdeable::from), } } async fn add_movie(&mut self, add_movie_body_option: Option) -> Result { info!("Adding new movie to Radarr"); + let event = RadarrEvent::AddMovie(None); let body = if let Some(add_movie_body) = add_movie_body_option { add_movie_body } else { @@ -261,7 +300,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let AddMovieModal { root_folder_list, @@ -327,7 +366,7 @@ impl<'a, 'b> Network<'a, 'b> { monitored: true, quality_profile_id, tags: tag_ids_vec, - add_options: AddOptions { + add_options: AddMovieOptions { monitor, search_for_movie: true, }, @@ -337,11 +376,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Add movie body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddMovie(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -349,8 +384,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn add_root_folder(&mut self, root_folder: Option) -> Result { + async fn add_radarr_root_folder(&mut self, root_folder: Option) -> Result { info!("Adding new root folder to Radarr"); + let event = RadarrEvent::AddRootFolder(None); let body = if let Some(path) = root_folder { AddRootFolderBody { path } } else { @@ -372,11 +408,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Add root folder body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddRootFolder(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -384,14 +416,17 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn add_tag(&mut self, tag: String) -> Result { + async fn add_radarr_tag(&mut self, tag: String) -> Result { info!("Adding a new Radarr tag"); + let event = RadarrEvent::AddTag(String::new()); let request_props = self - .radarr_request_props_from( - RadarrEvent::AddTag(String::new()).resource(), + .request_props_from( + event, RequestMethod::Post, Some(json!({ "label": tag })), + None, + None, ) .await; @@ -402,14 +437,17 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_tag(&mut self, id: i64) -> Result<()> { + async fn delete_radarr_tag(&mut self, id: i64) -> Result<()> { info!("Deleting Radarr tag with id: {id}"); + let event = RadarrEvent::DeleteTag(id); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteTag(id).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -418,8 +456,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn clear_blocklist(&mut self) -> Result<()> { + async fn clear_radarr_blocklist(&mut self) -> Result<()> { info!("Clearing Radarr blocklist"); + let event = RadarrEvent::ClearBlocklist; let ids = self .app @@ -434,10 +473,12 @@ impl<'a, 'b> Network<'a, 'b> { .collect::>(); let request_props = self - .radarr_request_props_from( - RadarrEvent::ClearBlocklist.resource(), + .request_props_from( + event, RequestMethod::Delete, Some(json!({"ids": ids})), + None, + None, ) .await; @@ -446,7 +487,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { + async fn delete_radarr_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteBlocklistItem(None); let id = if let Some(b_id) = blocklist_item_id { b_id } else { @@ -464,10 +506,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr blocklist item for item with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteBlocklistItem(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -476,7 +520,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_download(&mut self, download_id: Option) -> Result<()> { + async fn delete_radarr_download(&mut self, download_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteDownload(None); let id = if let Some(dl_id) = download_id { dl_id } else { @@ -494,10 +539,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr download for download with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteDownload(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -506,7 +553,8 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn delete_indexer(&mut self, indexer_id: Option) -> Result<()> { + async fn delete_radarr_indexer(&mut self, indexer_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteIndexer(None); let id = if let Some(i_id) = indexer_id { i_id } else { @@ -524,10 +572,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr indexer for indexer with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteIndexer(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -537,6 +587,7 @@ impl<'a, 'b> Network<'a, 'b> { } async fn delete_movie(&mut self, delete_movie_params: Option) -> Result<()> { + let event = RadarrEvent::DeleteMovie(None); let (movie_id, delete_files, add_import_exclusion) = if let Some(params) = delete_movie_params { ( params.id, @@ -544,7 +595,7 @@ impl<'a, 'b> Network<'a, 'b> { params.add_list_exclusion, ) } else { - let movie_id = self.extract_movie_id().await; + let (movie_id, _) = self.extract_movie_id(None).await; let delete_files = self.app.lock().await.data.radarr_data.delete_movie_files; let add_import_exclusion = self.app.lock().await.data.radarr_data.add_list_exclusion; @@ -554,14 +605,14 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr movie with ID: {movie_id} with deleteFiles={delete_files} and addImportExclusion={add_import_exclusion}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{movie_id}?deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}", - RadarrEvent::DeleteMovie(None).resource() - ) - .as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{movie_id}")), + Some(format!( + "deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}" + )), ) .await; @@ -580,7 +631,8 @@ impl<'a, 'b> Network<'a, 'b> { resp } - async fn delete_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + async fn delete_radarr_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + let event = RadarrEvent::DeleteRootFolder(None); let id = if let Some(rf_id) = root_folder_id { rf_id } else { @@ -598,10 +650,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Deleting Radarr root folder for folder with id: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::DeleteRootFolder(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Delete, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -610,15 +664,19 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn download_release(&mut self, params: Option) -> Result { + async fn download_radarr_release( + &mut self, + params: Option, + ) -> Result { + let event = RadarrEvent::DownloadRelease(None); let body = if let Some(release_download_body) = params { - info!("Downloading release with params: {release_download_body:?}"); + info!("Downloading Radarr release with params: {release_download_body:?}"); release_download_body } else { - let movie_id = self.extract_movie_id().await; + let (movie_id, _) = self.extract_movie_id(None).await; let (guid, title, indexer_id) = { let app = self.app.lock().await; - let Release { + let RadarrRelease { guid, title, indexer_id, @@ -637,7 +695,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Downloading release: {title}"); - ReleaseDownloadBody { + RadarrReleaseDownloadBody { guid, indexer_id, movie_id, @@ -645,20 +703,20 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::DownloadRelease(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self - .handle_request::(request_props, |_, _| ()) + .handle_request::(request_props, |_, _| ()) .await } - async fn edit_all_indexer_settings(&mut self, params: Option) -> Result { + async fn edit_all_radarr_indexer_settings( + &mut self, + params: Option, + ) -> Result { info!("Updating Radarr indexer settings"); + let event = RadarrEvent::EditAllIndexerSettings(None); let body = if let Some(indexer_settings) = params { indexer_settings @@ -678,11 +736,7 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Indexer settings body: {body:?}"); let request_props = self - .radarr_request_props_from( - RadarrEvent::EditAllIndexerSettings(None).resource(), - RequestMethod::Put, - Some(body), - ) + .request_props_from(event, RequestMethod::Put, Some(body), None, None) .await; let resp = self @@ -699,18 +753,22 @@ impl<'a, 'b> Network<'a, 'b> { edit_collection_params: Option, ) -> Result<()> { info!("Editing Radarr collection"); - + let detail_event = RadarrEvent::GetCollections; + let event = RadarrEvent::EditCollection(None); info!("Fetching collection details"); + let collection_id = if let Some(ref params) = edit_collection_params { params.collection_id } else { self.extract_collection_id().await }; let request_props = self - .radarr_request_props_from( - format!("{}/{collection_id}", RadarrEvent::GetCollections.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{collection_id}")), + None, ) .await; @@ -811,14 +869,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit collection body: {detailed_collection_body:?}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{collection_id}", - RadarrEvent::EditCollection(None).resource() - ) - .as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_collection_body), + Some(format!("/{collection_id}")), + None, ) .await; @@ -827,7 +883,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn edit_indexer(&mut self, edit_indexer_params: Option) -> Result<()> { + async fn edit_radarr_indexer( + &mut self, + edit_indexer_params: Option, + ) -> Result<()> { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::EditIndexer(None); let id = if let Some(ref params) = edit_indexer_params { params.indexer_id } else { @@ -846,10 +907,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Fetching indexer details for indexer with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetIndexers.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -981,7 +1044,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -1061,10 +1124,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit indexer body: {detailed_indexer_body:?}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::EditIndexer(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_indexer_body), + Some(format!("/{id}")), + None, ) .await; @@ -1075,23 +1140,23 @@ impl<'a, 'b> Network<'a, 'b> { async fn edit_movie(&mut self, edit_movie_params: Option) -> Result<()> { info!("Editing Radarr movie"); + let detail_event = RadarrEvent::GetMovieDetails(None); + let event = RadarrEvent::EditMovie(None); - let movie_id = if let Some(ref params) = edit_movie_params { - params.movie_id + let (movie_id, _) = if let Some(ref params) = edit_movie_params { + self.extract_movie_id(Some(params.movie_id)).await } else { - self.extract_movie_id().await + self.extract_movie_id(None).await }; info!("Fetching movie details for movie with ID: {movie_id}"); let request_props = self - .radarr_request_props_from( - format!( - "{}/{movie_id}", - RadarrEvent::GetMovieDetails(None).resource() - ) - .as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{movie_id}")), + None, ) .await; @@ -1164,7 +1229,7 @@ impl<'a, 'b> Network<'a, 'b> { .tags .text .clone(); - let tag_ids_vec = self.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = self.extract_and_add_radarr_tag_ids_vec(tags).await; let mut app = self.app.lock().await; let params = { @@ -1209,10 +1274,12 @@ impl<'a, 'b> Network<'a, 'b> { debug!("Edit movie body: {detailed_movie_body:?}"); let request_props = self - .radarr_request_props_from( - format!("{}/{movie_id}", RadarrEvent::EditMovie(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Put, Some(detailed_movie_body), + Some(format!("/{movie_id}")), + None, ) .await; @@ -1221,15 +1288,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_blocklist(&mut self) -> Result { - info!("Fetching blocklist"); + async fn get_radarr_blocklist(&mut self) -> Result { + info!("Fetching Radarr blocklist"); + let event = RadarrEvent::GetBlocklist; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetBlocklist.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1249,13 +1313,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_collections(&mut self) -> Result> { info!("Fetching Radarr collections"); + let event = RadarrEvent::GetCollections; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetCollections.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1274,12 +1335,17 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_credits(&mut self, movie_id: Option) -> Result> { info!("Fetching Radarr movie credits"); + let event = RadarrEvent::GetMovieCredits(None); + let (_, movie_id_param) = self.extract_movie_id(movie_id).await; - let request_uri = self - .append_movie_id_param(RadarrEvent::GetMovieCredits(None).resource(), movie_id) - .await; let request_props = self - .radarr_request_props_from(request_uri.as_str(), RequestMethod::Get, None::<()>) + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(movie_id_param), + ) .await; self @@ -1319,15 +1385,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_diskspace(&mut self) -> Result> { + async fn get_radarr_diskspace(&mut self) -> Result> { info!("Fetching Radarr disk space"); + let event = RadarrEvent::GetDiskSpace; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetOverview.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1337,15 +1400,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_downloads(&mut self) -> Result { + async fn get_radarr_downloads(&mut self) -> Result { info!("Fetching Radarr downloads"); + let event = RadarrEvent::GetDownloads; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetDownloads.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1359,15 +1419,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_host_config(&mut self) -> Result { + async fn get_radarr_host_config(&mut self) -> Result { info!("Fetching Radarr host config"); + let event = RadarrEvent::GetHostConfig; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetHostConfig.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1375,15 +1432,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_indexers(&mut self) -> Result> { + async fn get_radarr_indexers(&mut self) -> Result> { info!("Fetching Radarr indexers"); + let event = RadarrEvent::GetIndexers; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetIndexers.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1393,15 +1447,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_all_indexer_settings(&mut self) -> Result { + async fn get_all_radarr_indexer_settings(&mut self) -> Result { info!("Fetching Radarr indexer settings"); + let event = RadarrEvent::GetAllIndexerSettings; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetAllIndexerSettings.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1415,15 +1466,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_healthcheck(&mut self) -> Result<()> { + async fn get_radarr_healthcheck(&mut self) -> Result<()> { info!("Performing Radarr health check"); + let event = RadarrEvent::HealthCheck; let request_props = self - .radarr_request_props_from( - RadarrEvent::HealthCheck.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1431,16 +1479,16 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_logs(&mut self, events: Option) -> Result { + async fn get_radarr_logs(&mut self, events: Option) -> Result { info!("Fetching Radarr logs"); + let event = RadarrEvent::GetLogs(events); - let resource = format!( - "{}?pageSize={}&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(events).resource(), + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", events.unwrap_or(500) ); let request_props = self - .radarr_request_props_from(&resource, RequestMethod::Get, None::<()>) + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) .await; self @@ -1480,19 +1528,18 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movie_details(&mut self, movie_id: Option) -> Result { info!("Fetching Radarr movie details"); + let event = RadarrEvent::GetMovieDetails(None); + let (id, _) = self.extract_movie_id(movie_id).await; - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; info!("Fetching movie details for movie with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetMovieDetails(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -1637,12 +1684,17 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movie_history(&mut self, movie_id: Option) -> Result> { info!("Fetching Radarr movie history"); + let event = RadarrEvent::GetMovieHistory(None); - let request_uri = self - .append_movie_id_param(RadarrEvent::GetMovieHistory(None).resource(), movie_id) - .await; + let (_, movie_id_param) = self.extract_movie_id(movie_id).await; let request_props = self - .radarr_request_props_from(request_uri.as_str(), RequestMethod::Get, None::<()>) + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(movie_id_param), + ) .await; self @@ -1668,13 +1720,10 @@ impl<'a, 'b> Network<'a, 'b> { async fn get_movies(&mut self) -> Result> { info!("Fetching Radarr library"); + let event = RadarrEvent::GetMovies; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetMovies.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1691,15 +1740,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_quality_profiles(&mut self) -> Result> { + async fn get_radarr_quality_profiles(&mut self) -> Result> { info!("Fetching Radarr quality profiles"); + let event = RadarrEvent::GetQualityProfiles; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetQualityProfiles.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1712,15 +1758,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_queued_events(&mut self) -> Result> { + async fn get_queued_radarr_events(&mut self) -> Result> { info!("Fetching Radarr queued events"); + let event = RadarrEvent::GetQueuedEvents; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetQueuedEvents.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1734,24 +1777,23 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_releases(&mut self, movie_id: Option) -> Result> { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + async fn get_movie_releases(&mut self, movie_id: Option) -> Result> { + let (id, movie_id_param) = self.extract_movie_id(movie_id).await; info!("Fetching releases for movie with ID: {id}"); + let event = RadarrEvent::GetReleases(None); let request_props = self - .radarr_request_props_from( - format!("{}?movieId={id}", RadarrEvent::GetReleases(None).resource()).as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + None, + Some(movie_id_param), ) .await; self - .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { if app.data.radarr_data.movie_details_modal.is_none() { app.data.radarr_data.movie_details_modal = Some(MovieDetailsModal::default()); } @@ -1768,15 +1810,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_root_folders(&mut self) -> Result> { + async fn get_radarr_root_folders(&mut self) -> Result> { info!("Fetching Radarr root folders"); + let event = RadarrEvent::GetRootFolders; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetRootFolders.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1786,15 +1825,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_security_config(&mut self) -> Result { + async fn get_radarr_security_config(&mut self) -> Result { info!("Fetching Radarr security config"); + let event = RadarrEvent::GetSecurityConfig; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetSecurityConfig.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1802,15 +1838,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_status(&mut self) -> Result { + async fn get_radarr_status(&mut self) -> Result { info!("Fetching Radarr system status"); + let event = RadarrEvent::GetStatus; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetStatus.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1821,15 +1854,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_tags(&mut self) -> Result> { + async fn get_radarr_tags(&mut self) -> Result> { info!("Fetching Radarr tags"); + let event = RadarrEvent::GetTags; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetTags.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1842,33 +1872,27 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn get_tasks(&mut self) -> Result> { + async fn get_radarr_tasks(&mut self) -> Result> { info!("Fetching Radarr tasks"); + let event = RadarrEvent::GetTasks; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetTasks.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self - .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { app.data.radarr_data.tasks.set_items(tasks_vec); }) .await } - async fn get_updates(&mut self) -> Result> { + async fn get_radarr_updates(&mut self) -> Result> { info!("Fetching Radarr updates"); + let event = RadarrEvent::GetUpdates; let request_props = self - .radarr_request_props_from( - RadarrEvent::GetUpdates.resource(), - RequestMethod::Get, - None::<()>, - ) + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) .await; self @@ -1943,6 +1967,7 @@ impl<'a, 'b> Network<'a, 'b> { async fn search_movie(&mut self, query: Option) -> Result> { info!("Searching for specific Radarr movie"); + let event = RadarrEvent::SearchNewMovie(None); let search = if let Some(search_query) = query { Ok(search_query.into()) } else { @@ -1960,15 +1985,12 @@ impl<'a, 'b> Network<'a, 'b> { match search { Ok(search_string) => { let request_props = self - .radarr_request_props_from( - format!( - "{}?term={}", - RadarrEvent::SearchNewMovie(None).resource(), - encode(&search_string.text) - ) - .as_str(), + .request_props_from( + event, RequestMethod::Get, None::<()>, + None, + Some(format!("term={}", encode(&search_string.text))), ) .await; @@ -2001,7 +2023,8 @@ impl<'a, 'b> Network<'a, 'b> { } } - async fn start_task(&mut self, task: Option) -> Result { + async fn start_radarr_task(&mut self, task: Option) -> Result { + let event = RadarrEvent::StartTask(None); let task_name = if let Some(t_name) = task { t_name } else { @@ -2022,11 +2045,7 @@ impl<'a, 'b> Network<'a, 'b> { let body = CommandBody { name: task_name }; let request_props = self - .radarr_request_props_from( - RadarrEvent::StartTask(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2034,7 +2053,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn test_indexer(&mut self, indexer_id: Option) -> Result { + async fn test_radarr_indexer(&mut self, indexer_id: Option) -> Result { + let detail_event = RadarrEvent::GetIndexers; + let event = RadarrEvent::TestIndexer(None); let id = if let Some(i_id) = indexer_id { i_id } else { @@ -2053,10 +2074,12 @@ impl<'a, 'b> Network<'a, 'b> { info!("Fetching indexer details for indexer with ID: {id}"); let request_props = self - .radarr_request_props_from( - format!("{}/{id}", RadarrEvent::GetIndexers.resource()).as_str(), + .request_props_from( + detail_event, RequestMethod::Get, None::<()>, + Some(format!("/{id}")), + None, ) .await; @@ -2071,11 +2094,7 @@ impl<'a, 'b> Network<'a, 'b> { info!("Testing indexer"); let mut request_props = self - .radarr_request_props_from( - RadarrEvent::TestIndexer(None).resource(), - RequestMethod::Post, - Some(test_body), - ) + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) .await; request_props.ignore_status_code = true; @@ -2093,15 +2112,12 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn test_all_indexers(&mut self) -> Result> { - info!("Testing all indexers"); + async fn test_all_radarr_indexers(&mut self) -> Result> { + info!("Testing all Radarr indexers"); + let event = RadarrEvent::TestAllIndexers; let mut request_props = self - .radarr_request_props_from( - RadarrEvent::TestAllIndexers.resource(), - RequestMethod::Post, - None, - ) + .request_props_from(event, RequestMethod::Post, None, None, None) .await; request_props.ignore_status_code = true; @@ -2143,12 +2159,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn trigger_automatic_search(&mut self, movie_id: Option) -> Result { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + async fn trigger_automatic_movie_search(&mut self, movie_id: Option) -> Result { + let event = RadarrEvent::TriggerAutomaticSearch(movie_id); + let (id, _) = self.extract_movie_id(movie_id).await; info!("Searching indexers for movie with ID: {id}"); let body = MovieCommandBody { name: "MoviesSearch".to_owned(), @@ -2156,11 +2169,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::TriggerAutomaticSearch(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2170,17 +2179,14 @@ impl<'a, 'b> Network<'a, 'b> { async fn update_all_movies(&mut self) -> Result { info!("Updating all movies"); + let event = RadarrEvent::UpdateAllMovies; let body = MovieCommandBody { name: "RefreshMovie".to_owned(), movie_ids: Vec::new(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateAllMovies.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2188,12 +2194,9 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn update_and_scan(&mut self, movie_id: Option) -> Result { - let id = if let Some(m_id) = movie_id { - m_id - } else { - self.extract_movie_id().await - }; + async fn update_and_scan_movie(&mut self, movie_id: Option) -> Result { + let (id, _) = self.extract_movie_id(movie_id).await; + let event = RadarrEvent::UpdateAndScan(None); info!("Updating and scanning movie with ID: {id}"); let body = MovieCommandBody { name: "RefreshMovie".to_owned(), @@ -2201,11 +2204,7 @@ impl<'a, 'b> Network<'a, 'b> { }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateAndScan(None).resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2215,16 +2214,13 @@ impl<'a, 'b> Network<'a, 'b> { async fn update_collections(&mut self) -> Result { info!("Updating collections"); + let event = RadarrEvent::UpdateCollections; let body = CommandBody { name: "RefreshCollections".to_owned(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateCollections.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2232,18 +2228,15 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn update_downloads(&mut self) -> Result { - info!("Updating downloads"); + async fn update_radarr_downloads(&mut self) -> Result { + info!("Updating Radarr downloads"); + let event = RadarrEvent::UpdateDownloads; let body = CommandBody { name: "RefreshMonitoredDownloads".to_owned(), }; let request_props = self - .radarr_request_props_from( - RadarrEvent::UpdateDownloads.resource(), - RequestMethod::Post, - Some(body), - ) + .request_props_from(event, RequestMethod::Post, Some(body), None, None) .await; self @@ -2251,45 +2244,7 @@ impl<'a, 'b> Network<'a, 'b> { .await } - async fn radarr_request_props_from( - &self, - resource: &str, - method: RequestMethod, - body: Option, - ) -> RequestProps { - let app = self.app.lock().await; - let RadarrConfig { - host, - port, - uri, - api_token, - ssl_cert_path, - } = &app.config.radarr; - let uri = if let Some(radarr_uri) = uri { - format!("{radarr_uri}/api/v3{resource}") - } else { - let protocol = if ssl_cert_path.is_some() { - "https" - } else { - "http" - }; - let host = host.as_ref().unwrap(); - format!( - "{protocol}://{host}:{}/api/v3{resource}", - port.unwrap_or(7878) - ) - }; - - RequestProps { - uri, - method, - body, - api_token: api_token.to_owned(), - ignore_status_code: false, - } - } - - async fn extract_and_add_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + async fn extract_and_add_radarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { let tags_map = self.app.lock().await.data.radarr_data.tags_map.clone(); let tags = edit_tags.clone(); let missing_tags_vec = edit_tags @@ -2299,7 +2254,7 @@ impl<'a, 'b> Network<'a, 'b> { for tag in missing_tags_vec { self - .add_tag(tag.trim().to_owned()) + .add_radarr_tag(tag.trim().to_owned()) .await .expect("Unable to add tag"); } @@ -2319,16 +2274,21 @@ impl<'a, 'b> Network<'a, 'b> { .collect() } - async fn extract_movie_id(&mut self) -> i64 { - self - .app - .lock() - .await - .data - .radarr_data - .movies - .current_selection() - .id + async fn extract_movie_id(&mut self, movie_id: Option) -> (i64, String) { + let movie_id = if let Some(id) = movie_id { + id + } else { + self + .app + .lock() + .await + .data + .radarr_data + .movies + .current_selection() + .id + }; + (movie_id, format!("movieId={movie_id}")) } async fn extract_collection_id(&mut self) -> i64 { @@ -2342,15 +2302,6 @@ impl<'a, 'b> Network<'a, 'b> { .current_selection() .id } - - async fn append_movie_id_param(&mut self, resource: &str, movie_id: Option) -> String { - let movie_id = if let Some(id) = movie_id { - id - } else { - self.extract_movie_id().await - }; - format!("{resource}?movieId={movie_id}") - } } fn get_movie_status(has_file: bool, downloads_vec: &[DownloadRecord], movie_id: i64) -> String { diff --git a/src/network/radarr_network_tests.rs b/src/network/radarr_network_tests.rs index 67500b6..c75ffd3 100644 --- a/src/network/radarr_network_tests.rs +++ b/src/network/radarr_network_tests.rs @@ -4,7 +4,7 @@ mod test { use bimap::BiMap; use chrono::{DateTime, Utc}; - use mockito::{Matcher, Mock, Server, ServerGuard}; + use mockito::{Matcher, Server}; use pretty_assertions::{assert_eq, assert_str_eq}; use reqwest::Client; use rstest::rstest; @@ -13,14 +13,18 @@ mod test { use tokio::sync::Mutex; use tokio_util::sync::CancellationToken; + use crate::app::ServarrConfig; use crate::models::radarr_models::{ - BlocklistItem, BlocklistItemMovie, CollectionMovie, IndexerField, Language, MediaInfo, - MinimumAvailability, Monitor, MovieCollection, MovieFile, Quality, QualityWrapper, Rating, - RatingsList, + BlocklistItem, BlocklistItemMovie, CollectionMovie, MediaInfo, MinimumAvailability, + MovieCollection, MovieFile, MovieMonitor, Rating, RatingsList, }; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; + use crate::models::servarr_models::{ + HostConfig, IndexerField, Language, Quality, QualityWrapper, + }; use crate::models::stateful_table::SortOption; use crate::models::HorizontallyScrollableText; + use crate::network::network_tests::test_utils::mock_servarr_api; use crate::App; use super::super::*; @@ -30,6 +34,7 @@ mod test { "title": "Test", "tmdbId": 1234, "originalLanguage": { + "id": 1, "name": "English" }, "sizeOnDisk": 3543348019, @@ -160,6 +165,18 @@ mod test { assert_str_eq!(event.resource(), "/rootfolder"); } + #[rstest] + fn test_resource_tag( + #[values( + RadarrEvent::AddTag(String::new()), + RadarrEvent::GetTags, + RadarrEvent::DeleteTag(0) + )] + event: RadarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + #[rstest] fn test_resource_release( #[values(RadarrEvent::GetReleases(None), RadarrEvent::DownloadRelease(None))] @@ -206,10 +223,9 @@ mod test { #[case(RadarrEvent::SearchNewMovie(None), "/movie/lookup")] #[case(RadarrEvent::GetMovieCredits(None), "/credit")] #[case(RadarrEvent::GetMovieHistory(None), "/history/movie")] - #[case(RadarrEvent::GetOverview, "/diskspace")] + #[case(RadarrEvent::GetDiskSpace, "/diskspace")] #[case(RadarrEvent::GetQualityProfiles, "/qualityprofile")] #[case(RadarrEvent::GetStatus, "/system/status")] - #[case(RadarrEvent::GetTags, "/tag")] #[case(RadarrEvent::GetTasks, "/system/task")] #[case(RadarrEvent::GetUpdates, "/update")] #[case(RadarrEvent::TestIndexer(None), "/indexer/test")] @@ -228,13 +244,15 @@ mod test { } #[tokio::test] - async fn test_handle_get_healthcheck_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_get_radarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, None, None, - RadarrEvent::HealthCheck.resource(), + RadarrEvent::HealthCheck, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -245,8 +263,8 @@ mod test { } #[tokio::test] - async fn test_handle_get_diskspace_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_get_radarr_diskspace_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([ @@ -260,7 +278,9 @@ mod test { } ])), None, - RadarrEvent::GetOverview.resource(), + RadarrEvent::GetDiskSpace, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -276,7 +296,7 @@ mod test { ]; if let RadarrSerdeable::DiskSpaces(disk_space) = network - .handle_radarr_event(RadarrEvent::GetOverview) + .handle_radarr_event(RadarrEvent::GetDiskSpace) .await .unwrap() { @@ -291,7 +311,7 @@ mod test { #[tokio::test] async fn test_handle_get_status_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!({ @@ -299,7 +319,9 @@ mod test { "startTime": "2023-02-25T20:16:43Z" })), None, - RadarrEvent::GetStatus.resource(), + RadarrEvent::GetStatus, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -357,12 +379,14 @@ mod test { ..movie() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([movie_1, movie_2])), None, - RadarrEvent::GetMovies.resource(), + RadarrEvent::GetMovies, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.movies.sort_asc = true; @@ -411,12 +435,14 @@ mod test { *movie_1.get_mut("title").unwrap() = json!("z test"); *movie_2.get_mut("id").unwrap() = json!(2); *movie_2.get_mut("title").unwrap() = json!("A test"); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(json!([movie_1, movie_2])), None, - RadarrEvent::GetMovies.resource(), + RadarrEvent::GetMovies, + None, + None, ) .await; app_arc @@ -474,16 +500,17 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); - let resource = format!("{}?movieId=1", RadarrEvent::GetReleases(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(release_json), None, - &resource, + RadarrEvent::GetReleases(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -533,16 +560,17 @@ mod test { "rejections": [ "Unknown quality profile", "Release is already mapped" ], "seeders": 2, "leechers": 1, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "quality": { "quality": { "name": "HD - 1080p" }} }]); - let resource = format!("{}?movieId=1", RadarrEvent::GetReleases(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(release_json), None, - &resource, + RadarrEvent::GetReleases(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -580,7 +608,7 @@ mod test { let add_movie_search_result_json = json!([{ "tmdbId": 1234, "title": "Test", - "originalLanguage": { "name": "English" }, + "originalLanguage": { "id": 1, "name": "English" }, "status": "released", "overview": "New movie blah blah blah", "genres": ["cool", "family", "fun"], @@ -598,16 +626,14 @@ mod test { } } }]); - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(add_movie_search_result_json), None, - &resource, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), ) .await; app_arc.lock().await.data.radarr_data.add_movie_search = Some("test term".into()); @@ -647,7 +673,7 @@ mod test { let add_movie_search_result_json = json!([{ "tmdbId": 1234, "title": "Test", - "originalLanguage": { "name": "English" }, + "originalLanguage": { "id": 1, "name": "English" }, "status": "released", "overview": "New movie blah blah blah", "genres": ["cool", "family", "fun"], @@ -665,16 +691,14 @@ mod test { } } }]); - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(add_movie_search_result_json), None, - &resource, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -690,16 +714,18 @@ mod test { } #[tokio::test] - async fn test_handle_start_task_event() { + async fn test_handle_start_radarr_task_event() { let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "ApplicationCheckUpdate" })), Some(response.clone()), None, - RadarrEvent::StartTask(None).resource(), + RadarrEvent::StartTask(None), + None, + None, ) .await; app_arc @@ -708,9 +734,9 @@ mod test { .data .radarr_data .tasks - .set_items(vec![Task { - task_name: TaskName::default(), - ..Task::default() + .set_items(vec![RadarrTask { + task_name: RadarrTaskName::default(), + ..RadarrTask::default() }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -725,38 +751,17 @@ mod test { } #[tokio::test] - async fn test_handle_start_task_event_uses_provided_task_name() { - let response = json!({ "test": "test"}); - let (async_server, app_arc, _server) = mock_radarr_api( - RequestMethod::Post, - Some(json!({ - "name": "ApplicationCheckUpdate" - })), - Some(response.clone()), + async fn test_handle_search_new_movie_event_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, None, - RadarrEvent::StartTask(None).resource(), + Some(json!([])), + None, + RadarrEvent::SearchNewMovie(None), + None, + Some("term=test%20term"), ) .await; - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - if let RadarrSerdeable::Value(value) = network - .handle_radarr_event(RadarrEvent::StartTask(Some(TaskName::default()))) - .await - .unwrap() - { - async_server.assert_async().await; - assert_eq!(value, response); - } - } - - #[tokio::test] - async fn test_handle_search_new_movie_event_no_results() { - let resource = format!( - "{}?term=test%20term", - RadarrEvent::SearchNewMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Get, None, Some(json!([])), None, &resource).await; app_arc.lock().await.data.radarr_data.add_movie_search = Some("test term".into()); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -801,13 +806,13 @@ mod test { .unwrap(), ); let mut app = App::default(); - let radarr_config = RadarrConfig { + let radarr_config = ServarrConfig { host, port, api_token: "test1234".to_owned(), - ..RadarrConfig::default() + ..ServarrConfig::default() }; - app.config.radarr = radarr_config; + app.config.radarr = Some(radarr_config); let app_arc = Arc::new(Mutex::new(app)); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -831,7 +836,34 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_error() { + async fn test_handle_start_radarr_task_event_uses_provided_task_name() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationCheckUpdate" + })), + Some(response.clone()), + None, + RadarrEvent::StartTask(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let RadarrSerdeable::Value(value) = network + .handle_radarr_event(RadarrEvent::StartTask(Some(RadarrTaskName::default()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + + #[tokio::test] + async fn test_handle_test_radarr_indexer_event_error() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -861,13 +893,14 @@ mod test { "errorMessage": "test failure", "severity": "error" }]); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -906,7 +939,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_success() { + async fn test_handle_test_radarr_indexer_event_success() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -929,13 +962,14 @@ mod test { "tags": [1], "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -974,7 +1008,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_indexer_event_success_uses_provided_id() { + async fn test_handle_test_radarr_indexer_event_success_uses_provided_id() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -997,13 +1031,14 @@ mod test { "tags": [1], "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_test_server = server @@ -1031,7 +1066,7 @@ mod test { } #[tokio::test] - async fn test_handle_test_all_indexers_event() { + async fn test_handle_test_all_radarr_indexers_event() { let indexers = vec![ Indexer { id: 1, @@ -1079,12 +1114,14 @@ mod test { ] }]); let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, None, Some(response_json), Some(400), - RadarrEvent::TestAllIndexers.resource(), + RadarrEvent::TestAllIndexers, + None, + None, ) .await; app_arc @@ -1126,8 +1163,8 @@ mod test { } #[tokio::test] - async fn test_handle_trigger_automatic_search_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_trigger_automatic_movie_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "MoviesSearch", @@ -1135,7 +1172,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::TriggerAutomaticSearch(None).resource(), + RadarrEvent::TriggerAutomaticSearch(None), + None, + None, ) .await; app_arc @@ -1156,8 +1195,8 @@ mod test { } #[tokio::test] - async fn test_handle_trigger_automatic_search_event_uses_provided_id() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_trigger_automatic_movie_search_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "MoviesSearch", @@ -1165,7 +1204,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::TriggerAutomaticSearch(None).resource(), + RadarrEvent::TriggerAutomaticSearch(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1179,8 +1220,8 @@ mod test { } #[tokio::test] - async fn test_handle_update_and_scan_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_update_and_scan_movie_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1188,7 +1229,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAndScan(None).resource(), + RadarrEvent::UpdateAndScan(None), + None, + None, ) .await; app_arc @@ -1209,8 +1252,8 @@ mod test { } #[tokio::test] - async fn test_handle_update_and_scan_event_uses_provied_movie_id() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_update_and_scan_movie_event_uses_provied_movie_id() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1218,7 +1261,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAndScan(None).resource(), + RadarrEvent::UpdateAndScan(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1233,7 +1278,7 @@ mod test { #[tokio::test] async fn test_handle_update_all_movies_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMovie", @@ -1241,7 +1286,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::UpdateAllMovies.resource(), + RadarrEvent::UpdateAllMovies, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1255,15 +1302,17 @@ mod test { } #[tokio::test] - async fn test_handle_update_downloads_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_update_radarr_downloads_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshMonitoredDownloads" })), Some(json!({})), None, - RadarrEvent::UpdateDownloads.resource(), + RadarrEvent::UpdateDownloads, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1278,14 +1327,16 @@ mod test { #[tokio::test] async fn test_handle_update_collections_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "name": "RefreshCollections" })), Some(json!({})), None, - RadarrEvent::UpdateCollections.resource(), + RadarrEvent::UpdateCollections, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1300,14 +1351,15 @@ mod test { #[tokio::test] async fn test_handle_get_movie_details_event() { - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; app_arc @@ -1394,14 +1446,15 @@ mod test { #[tokio::test] async fn test_handle_get_movie_details_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); let response: Movie = serde_json::from_str(MOVIE_JSON).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1422,6 +1475,7 @@ mod test { "id": 1, "title": "Test", "originalLanguage": { + "id": 1, "name": "English" }, "sizeOnDisk": 0, @@ -1440,13 +1494,14 @@ mod test { "minimumAvailability": "released", "ratings": {} }); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_json_with_missing_fields), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; app_arc @@ -1506,22 +1561,20 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); let response: Vec = serde_json::from_value(movie_history_item_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -1562,22 +1615,20 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); let response: Vec = serde_json::from_value(movie_history_item_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -1597,20 +1648,18 @@ mod test { let movie_history_item_json = json!([{ "sourceTitle": "Test", "quality": { "quality": { "name": "HD - 1080p" }}, - "languages": [ { "name": "English" } ], + "languages": [ { "id": 1, "name": "English" } ], "date": "2022-12-30T07:37:56Z", "eventType": "grabbed" }]); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieHistory(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(movie_history_item_json), None, - &resource, + RadarrEvent::GetMovieHistory(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -1645,14 +1694,14 @@ mod test { #[rstest] #[tokio::test] - async fn test_handle_get_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + async fn test_handle_get_radarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { let blocklist_json = json!({"records": [{ "id": 123, "movieId": 1007, "sourceTitle": "z movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1661,7 +1710,7 @@ mod test { "id": 1007, "title": "z movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1686,9 +1735,9 @@ mod test { "id": 456, "movieId": 2001, "sourceTitle": "A Movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1697,7 +1746,7 @@ mod test { "id": 2001, "title": "A Movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1740,12 +1789,14 @@ mod test { ..blocklist_item() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(blocklist_json), None, - RadarrEvent::GetBlocklist.resource(), + RadarrEvent::GetBlocklist, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; @@ -1792,9 +1843,9 @@ mod test { "id": 123, "movieId": 1007, "sourceTitle": "z movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1803,7 +1854,7 @@ mod test { "id": 1007, "title": "z movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1828,9 +1879,9 @@ mod test { "id": 456, "movieId": 2001, "sourceTitle": "A Movie", - "languages": [{"name": "English"}], + "languages": [{"id": 1, "name": "English"}], "quality": {"quality": {"name": "HD - 1080p"}}, - "customFormats": [{"name": "English"}], + "customFormats": [{"id": 1, "name": "English"}], "date": "2024-02-10T07:28:45Z", "protocol": "usenet", "indexer": "DrunkenSlug (Prowlarr)", @@ -1839,7 +1890,7 @@ mod test { "id": 2001, "title": "A Movie", "tmdbId": 1234, - "originalLanguage": {"name": "English"}, + "originalLanguage": {"id": 1, "name": "English"}, "sizeOnDisk": 3543348019i64, "status": "Downloaded", "overview": "Blah blah blah", @@ -1861,12 +1912,14 @@ mod test { }, }, }]}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(blocklist_json), None, - RadarrEvent::GetBlocklist.resource(), + RadarrEvent::GetBlocklist, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.blocklist.sort_asc = true; @@ -1983,12 +2036,14 @@ mod test { ..collection() }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(collections_json), None, - RadarrEvent::GetCollections.resource(), + RadarrEvent::GetCollections, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.collections.sort_asc = true; @@ -2090,12 +2145,14 @@ mod test { } }], }]); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(collections_json), None, - RadarrEvent::GetCollections.resource(), + RadarrEvent::GetCollections, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.collections.sort_asc = true; @@ -2140,7 +2197,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_downloads_event() { + async fn test_handle_get_radarr_downloads_event() { let downloads_response_json = json!({ "records": [{ "title": "Test Download Title", @@ -2156,12 +2213,14 @@ mod test { }); let response: DownloadsResponse = serde_json::from_value(downloads_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(downloads_response_json), None, - RadarrEvent::GetDownloads.resource(), + RadarrEvent::GetDownloads, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2181,7 +2240,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_host_config_event() { + async fn test_handle_get_radarr_host_config_event() { let host_config_response = json!({ "bindAddress": "*", "port": 7878, @@ -2194,12 +2253,14 @@ mod test { "sslCertPassword": "test" }); let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(host_config_response), None, - RadarrEvent::GetHostConfig.resource(), + RadarrEvent::GetHostConfig, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2215,7 +2276,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_indexers_event() { + async fn test_handle_get_radarr_indexers_event() { let indexers_response_json = json!([{ "enableRss": true, "enableAutomaticSearch": true, @@ -2247,12 +2308,14 @@ mod test { "id": 1 }]); let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexers_response_json), None, - RadarrEvent::GetIndexers.resource(), + RadarrEvent::GetIndexers, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2286,12 +2349,14 @@ mod test { }); let response: IndexerSettings = serde_json::from_value(indexer_settings_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_settings_response_json), None, - RadarrEvent::GetAllIndexerSettings.resource(), + RadarrEvent::GetAllIndexerSettings, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2323,12 +2388,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_settings_response_json), None, - RadarrEvent::GetAllIndexerSettings.resource(), + RadarrEvent::GetAllIndexerSettings, + None, + None, ) .await; app_arc.lock().await.data.radarr_data.indexer_settings = Some(IndexerSettings::default()); @@ -2347,7 +2414,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_queued_events_event() { + async fn test_handle_get_queued_radarr_events_event() { let queued_events_json = json!([{ "name": "RefreshMonitoredDownloads", "commandName": "Refresh Monitored Downloads", @@ -2371,12 +2438,14 @@ mod test { trigger: "scheduled".to_owned(), }; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(queued_events_json), None, - RadarrEvent::GetQueuedEvents.resource(), + RadarrEvent::GetQueuedEvents, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2396,11 +2465,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_logs_event() { - let resource = format!( - "{}?pageSize=500&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(None).resource() - ); + async fn test_handle_get_radarr_logs_event() { let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", @@ -2432,12 +2497,14 @@ mod test { ] }); let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(logs_response_json), None, - &resource, + RadarrEvent::GetLogs(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2466,11 +2533,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_logs_event_uses_provided_events() { - let resource = format!( - "{}?pageSize=1000&sortDirection=descending&sortKey=time", - RadarrEvent::GetLogs(Some(1000)).resource() - ); + async fn test_handle_get_radarr_logs_event_uses_provided_events() { let expected_logs = vec![ HorizontallyScrollableText::from( "2023-05-20 21:29:16 UTC|FATAL|RadarrError|Some.Big.Bad.Exception|test exception", @@ -2502,12 +2565,14 @@ mod test { ] }); let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(logs_response_json), None, - &resource, + RadarrEvent::GetLogs(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2536,19 +2601,21 @@ mod test { } #[tokio::test] - async fn test_handle_get_quality_profiles_event() { + async fn test_handle_get_radarr_quality_profiles_event() { let quality_profile_json = json!([{ "id": 2222, "name": "HD - 1080p" }]); let response: Vec = serde_json::from_value(quality_profile_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(quality_profile_json), None, - RadarrEvent::GetQualityProfiles.resource(), + RadarrEvent::GetQualityProfiles, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2568,18 +2635,20 @@ mod test { } #[tokio::test] - async fn test_handle_get_tags_event() { + async fn test_handle_get_radarr_tags_event() { let tags_json = json!([{ "id": 2222, "label": "usenet" }]); let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(tags_json), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::GetTags, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2599,7 +2668,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_tasks_event() { + async fn test_handle_get_radarr_tasks_event() { let tasks_json = json!([{ "name": "Application Check Update", "taskName": "ApplicationCheckUpdate", @@ -2616,32 +2685,34 @@ mod test { "nextExecution": "2023-05-20T21:29:16Z", "lastDuration": "00:00:00.5111547", }]); - let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); let expected_tasks = vec![ - Task { + RadarrTask { name: "Application Check Update".to_owned(), - task_name: TaskName::ApplicationCheckUpdate, + task_name: RadarrTaskName::ApplicationCheckUpdate, interval: 360, last_execution: timestamp, next_execution: timestamp, last_duration: "00:00:00.5111547".to_owned(), }, - Task { + RadarrTask { name: "Backup".to_owned(), - task_name: TaskName::Backup, + task_name: RadarrTaskName::Backup, interval: 10080, last_execution: timestamp, next_execution: timestamp, last_duration: "00:00:00.5111547".to_owned(), }, ]; - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(tasks_json), None, - RadarrEvent::GetTasks.resource(), + RadarrEvent::GetTasks, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2661,7 +2732,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_updates_event() { + async fn test_handle_get_radarr_updates_event() { let updates_json = json!([{ "version": "4.3.2.1", "releaseDate": "2023-04-15T02:02:53Z", @@ -2729,12 +2800,14 @@ mod test { * Killed bug 1 * Fixed bug 2" )); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(updates_json), None, - RadarrEvent::GetUpdates.resource(), + RadarrEvent::GetUpdates, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2754,15 +2827,17 @@ mod test { } #[tokio::test] - async fn test_handle_add_tag() { + async fn test_handle_add_radarr_tag() { let tag_json = json!({ "id": 3, "label": "testing" }); let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), Some(tag_json), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::AddTag(String::new()), + None, + None, ) .await; app_arc.lock().await.data.radarr_data.tags_map = @@ -2788,10 +2863,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_tag_event() { - let resource = format!("{}/1", RadarrEvent::DeleteTag(1).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_tag_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -2803,7 +2885,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_root_folders_event() { + async fn test_handle_get_radarr_root_folders_event() { let root_folder_json = json!([{ "id": 1, "path": "/nfs", @@ -2811,12 +2893,14 @@ mod test { "freeSpace": 219902325555200u64, }]); let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(root_folder_json), None, - RadarrEvent::GetRootFolders.resource(), + RadarrEvent::GetRootFolders, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2836,7 +2920,7 @@ mod test { } #[tokio::test] - async fn test_handle_get_security_config_event() { + async fn test_handle_get_radarr_security_config_event() { let security_config_response = json!({ "authenticationMethod": "forms", "authenticationRequired": "disabledForLocalAddresses", @@ -2847,12 +2931,14 @@ mod test { }); let response: SecurityConfig = serde_json::from_value(security_config_response.clone()).unwrap(); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(security_config_response), None, - RadarrEvent::GetSecurityConfig.resource(), + RadarrEvent::GetSecurityConfig, + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2883,16 +2969,14 @@ mod test { } ]); let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -2936,16 +3020,14 @@ mod test { } ]); let response: Vec = serde_json::from_value(credits_json.clone()).unwrap(); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); @@ -2975,16 +3057,14 @@ mod test { "type": "crew", } ]); - let resource = format!( - "{}?movieId=1", - RadarrEvent::GetMovieCredits(None).resource() - ); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Get, None, Some(credits_json), None, - &resource, + RadarrEvent::GetMovieCredits(None), + None, + Some("movieId=1"), ) .await; app_arc @@ -3011,12 +3091,16 @@ mod test { #[tokio::test] async fn test_handle_delete_movie_event() { - let resource = format!( - "{}/1?deleteFiles=true&addImportExclusion=true", - RadarrEvent::DeleteMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteMovie(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; { let mut app = app_arc.lock().await; app.data.radarr_data.movies.set_items(vec![movie()]); @@ -3037,12 +3121,16 @@ mod test { #[tokio::test] async fn test_handle_delete_movie_event_use_provided_params() { - let resource = format!( - "{}/1?deleteFiles=true&addImportExclusion=true", - RadarrEvent::DeleteMovie(None).resource() - ); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteMovie(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); let delete_movie_params = DeleteMovieParams { id: 1, @@ -3061,7 +3149,7 @@ mod test { } #[tokio::test] - async fn test_handle_clear_blocklist_event() { + async fn test_handle_clear_radarr_blocklist_event() { let blocklist_items = vec![ BlocklistItem { id: 1, @@ -3077,12 +3165,14 @@ mod test { }, ]; let expected_request_json = json!({ "ids": [1, 2, 3]}); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Delete, Some(expected_request_json), None, None, - RadarrEvent::ClearBlocklist.resource(), + RadarrEvent::ClearBlocklist, + None, + None, ) .await; app_arc @@ -3103,10 +3193,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_blocklist_item_event() { - let resource = format!("{}/1", RadarrEvent::DeleteBlocklistItem(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_blocklist_item_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3126,9 +3223,16 @@ mod test { #[tokio::test] async fn test_handle_delete_blocklist_item_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteBlocklistItem(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3140,10 +3244,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_download_event() { - let resource = format!("{}/1", RadarrEvent::DeleteDownload(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_download_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3162,10 +3273,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_download_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteDownload(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_download_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3177,10 +3295,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_indexer_event() { - let resource = format!("{}/1", RadarrEvent::DeleteIndexer(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_indexer_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3199,10 +3324,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_indexer_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteIndexer(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_indexer_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3214,10 +3346,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_root_folder_event() { - let resource = format!("{}/1", RadarrEvent::DeleteRootFolder(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; app_arc .lock() .await @@ -3236,10 +3375,17 @@ mod test { } #[tokio::test] - async fn test_handle_delete_root_folder_event_uses_provided_id() { - let resource = format!("{}/1", RadarrEvent::DeleteRootFolder(None).resource()); - let (async_server, app_arc, _server) = - mock_radarr_api(RequestMethod::Delete, None, None, None, &resource).await; + async fn test_handle_delete_radarr_root_folder_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + RadarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert!(network @@ -3253,7 +3399,7 @@ mod test { #[rstest] #[tokio::test] async fn test_handle_add_movie_event(#[values(true, false)] movie_details_context: bool) { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 1234, @@ -3270,7 +3416,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; @@ -3302,7 +3450,7 @@ mod test { .set_items(vec!["HD - 1080p".to_owned()]); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); @@ -3343,7 +3491,7 @@ mod test { #[tokio::test] async fn test_handle_add_movie_event_uses_provided_body() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 1234, @@ -3360,7 +3508,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; let body = AddMovieBody { @@ -3371,7 +3521,7 @@ mod test { monitored: true, quality_profile_id: 2222, tags: vec![1, 2], - add_options: AddOptions { + add_options: AddMovieOptions { monitor: "movieOnly".to_owned(), search_for_movie: true, }, @@ -3396,7 +3546,7 @@ mod test { #[tokio::test] async fn test_handle_add_movie_event_reuse_existing_table_if_search_already_performed() { - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "tmdbId": 5678, @@ -3413,7 +3563,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::AddMovie(None).resource(), + RadarrEvent::AddMovie(None), + None, + None, ) .await; @@ -3445,7 +3597,7 @@ mod test { .set_items(vec!["HD - 1080p".to_owned()]); add_movie_modal .monitor_list - .set_items(Vec::from_iter(Monitor::iter())); + .set_items(Vec::from_iter(MovieMonitor::iter())); add_movie_modal .minimum_availability_list .set_items(Vec::from_iter(MinimumAvailability::iter())); @@ -3494,15 +3646,17 @@ mod test { } #[tokio::test] - async fn test_handle_add_root_folder_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_add_radarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "path": "/nfs/test" })), Some(json!({})), None, - RadarrEvent::AddRootFolder(None).resource(), + RadarrEvent::AddRootFolder(None), + None, + None, ) .await; @@ -3525,15 +3679,17 @@ mod test { } #[tokio::test] - async fn test_handle_add_root_folder_event_uses_provided_path() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_add_radarr_root_folder_event_uses_provided_path() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "path": "/test/test" })), Some(json!({})), None, - RadarrEvent::AddRootFolder(None).resource(), + RadarrEvent::AddRootFolder(None), + None, + None, ) .await; @@ -3555,7 +3711,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_all_indexer_settings_event() { + async fn test_handle_edit_all_radarr_indexer_settings_event() { let indexer_settings_json = json!({ "minimumAge": 0, "maximumSize": 0, @@ -3567,12 +3723,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Put, Some(indexer_settings_json), None, None, - RadarrEvent::EditAllIndexerSettings(None).resource(), + RadarrEvent::EditAllIndexerSettings(None), + None, + None, ) .await; @@ -3595,7 +3753,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_all_indexer_settings_event_uses_provided_settings() { + async fn test_handle_edit_all_radarr_indexer_settings_event_uses_provided_settings() { let indexer_settings_json = json!({ "minimumAge": 0, "maximumSize": 0, @@ -3607,12 +3765,14 @@ mod test { "whitelistedHardcodedSubs": "", "id": 1 }); - let (async_server, app_arc, _server) = mock_radarr_api( + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Put, Some(indexer_settings_json), None, None, - RadarrEvent::EditAllIndexerSettings(None).resource(), + RadarrEvent::EditAllIndexerSettings(None), + None, + None, ) .await; @@ -3668,13 +3828,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3768,13 +3929,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(false); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3851,13 +4013,14 @@ mod test { *expected_body.get_mut("rootFolderPath").unwrap() = json!("/nfs/movies"); *expected_body.get_mut("searchOnAdd").unwrap() = json!(true); - let resource = format!("{}/123", RadarrEvent::GetCollections.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(detailed_collection_body), None, - &resource, + RadarrEvent::GetCollections, + Some("/123"), + None, ) .await; let async_edit_server = server @@ -3890,7 +4053,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event() { + async fn test_handle_edit_radarr_indexer_event() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -3938,13 +4101,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -3989,7 +4153,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + async fn test_handle_edit_radarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( ) { let indexer_details_json = json!({ "enableRss": true, @@ -4030,13 +4194,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4090,7 +4255,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + async fn test_handle_edit_radarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( ) { let indexer_details_json = json!({ "enableRss": true, @@ -4138,13 +4303,14 @@ mod test { "id": 1 }); - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4205,7 +4371,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters() { + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters() { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -4266,13 +4432,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4297,7 +4464,8 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters_defaults_to_previous_values() { + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters_defaults_to_previous_values( + ) { let indexer_details_json = json!({ "enableRss": true, "enableAutomaticSearch": true, @@ -4326,13 +4494,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json.clone()), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4357,7 +4526,7 @@ mod test { } #[tokio::test] - async fn test_handle_edit_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( + async fn test_handle_edit_radarr_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( ) { let indexer_details_json = json!({ "enableRss": true, @@ -4411,13 +4580,14 @@ mod test { ..EditIndexerParams::default() }; - let resource = format!("{}/1", RadarrEvent::GetIndexers.resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(indexer_details_json), None, - &resource, + RadarrEvent::GetIndexers, + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4450,13 +4620,14 @@ mod test { *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4516,13 +4687,14 @@ mod test { *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); *expected_body.get_mut("tags").unwrap() = json!([1, 2]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4558,13 +4730,14 @@ mod test { #[tokio::test] async fn test_handle_edit_movie_event_uses_provided_parameters_defaults_to_previous_values() { let expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4598,13 +4771,14 @@ mod test { let mut expected_body: Value = serde_json::from_str(MOVIE_JSON).unwrap(); *expected_body.get_mut("tags").unwrap() = json!([]); - let resource = format!("{}/1", RadarrEvent::GetMovieDetails(None).resource()); - let (async_details_server, app_arc, mut server) = mock_radarr_api( + let (async_details_server, app_arc, mut server) = mock_servarr_api( RequestMethod::Get, None, Some(serde_json::from_str(MOVIE_JSON).unwrap()), None, - &resource, + RadarrEvent::GetMovieDetails(None), + Some("/1"), + None, ) .await; let async_edit_server = server @@ -4634,8 +4808,8 @@ mod test { } #[tokio::test] - async fn test_handle_download_release_event() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_download_radarr_release_event() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "guid": "1234", @@ -4644,7 +4818,9 @@ mod test { })), Some(json!({})), None, - RadarrEvent::DownloadRelease(None).resource(), + RadarrEvent::DownloadRelease(None), + None, + None, ) .await; let mut movie_details_modal = MovieDetailsModal::default(); @@ -4670,8 +4846,8 @@ mod test { } #[tokio::test] - async fn test_handle_download_release_event_uses_provided_params() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_handle_download_radarr_release_event_uses_provided_params() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "guid": "1234", @@ -4680,11 +4856,13 @@ mod test { })), Some(json!({})), None, - RadarrEvent::DownloadRelease(None).resource(), + RadarrEvent::DownloadRelease(None), + None, + None, ) .await; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let params = ReleaseDownloadBody { + let params = RadarrReleaseDownloadBody { guid: "1234".to_owned(), indexer_id: 2, movie_id: 1, @@ -4699,7 +4877,7 @@ mod test { } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec() { + async fn test_extract_and_add_radarr_tag_ids_vec() { let app_arc = Arc::new(Mutex::new(App::default())); let tags = " test,hi ,, usenet ".to_owned(); { @@ -4713,19 +4891,21 @@ mod test { let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); assert_eq!( - network.extract_and_add_tag_ids_vec(tags).await, + network.extract_and_add_radarr_tag_ids_vec(tags).await, vec![2, 3, 1] ); } #[tokio::test] - async fn test_extract_and_add_tag_ids_vec_add_missing_tags_first() { - let (async_server, app_arc, _server) = mock_radarr_api( + async fn test_extract_and_add_radarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( RequestMethod::Post, Some(json!({ "label": "testing" })), Some(json!({ "id": 3, "label": "testing" })), None, - RadarrEvent::GetTags.resource(), + RadarrEvent::GetTags, + None, + None, ) .await; let tags = "usenet, test, testing".to_owned(); @@ -4740,7 +4920,7 @@ mod test { } let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - let tag_ids_vec = network.extract_and_add_tag_ids_vec(tags).await; + let tag_ids_vec = network.extract_and_add_radarr_tag_ids_vec(tags).await; async_server.assert_async().await; assert_eq!(tag_ids_vec, vec![1, 2, 3]); @@ -4769,7 +4949,31 @@ mod test { }]); let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - assert_eq!(network.extract_movie_id().await, 1); + let (id, movie_id_param) = network.extract_movie_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(movie_id_param, "movieId=1"); + } + + #[tokio::test] + async fn test_extract_movie_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .radarr_data + .movies + .set_items(vec![Movie { + id: 1, + ..Movie::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, movie_id_param) = network.extract_movie_id(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(movie_id_param, "movieId=2"); } #[tokio::test] @@ -4783,7 +4987,10 @@ mod test { app_arc.lock().await.data.radarr_data.movies = filtered_movies; let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - assert_eq!(network.extract_movie_id().await, 1); + let (id, movie_id_param) = network.extract_movie_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(movie_id_param, "movieId=1"); } #[tokio::test] @@ -4818,115 +5025,6 @@ mod test { assert_eq!(network.extract_collection_id().await, 1); } - #[tokio::test] - async fn test_append_movie_id_param() { - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc - .lock() - .await - .data - .radarr_data - .movies - .set_items(vec![Movie { - id: 1, - ..Movie::default() - }]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_str_eq!( - network.append_movie_id_param("/test", None).await, - "/test?movieId=1" - ); - } - - #[tokio::test] - async fn test_append_movie_id_param_uses_provided_id() { - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc - .lock() - .await - .data - .radarr_data - .movies - .set_items(vec![Movie { - id: 1, - ..Movie::default() - }]); - let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - assert_str_eq!( - network.append_movie_id_param("/test", Some(11)).await, - "/test?movieId=11" - ); - } - - #[tokio::test] - async fn test_radarr_request_props_from_default_radarr_config() { - let app_arc = Arc::new(Mutex::new(App::default())); - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "http://localhost:7878/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert!(request_props.api_token.is_empty()); - - app_arc.lock().await.config.radarr = RadarrConfig { - host: Some("192.168.0.123".to_owned()), - port: Some(8080), - api_token: "testToken1234".to_owned(), - ..RadarrConfig::default() - }; - } - - #[tokio::test] - async fn test_radarr_request_props_from_custom_radarr_config() { - let api_token = "testToken1234".to_owned(); - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc.lock().await.config.radarr = RadarrConfig { - host: Some("192.168.0.123".to_owned()), - port: Some(8080), - api_token: api_token.clone(), - ssl_cert_path: Some("/test/cert.crt".to_owned()), - ..RadarrConfig::default() - }; - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert_str_eq!(request_props.api_token, api_token); - } - - #[tokio::test] - async fn test_radarr_request_props_from_custom_radarr_config_using_uri_instead_of_host_and_port() - { - let api_token = "testToken1234".to_owned(); - let app_arc = Arc::new(Mutex::new(App::default())); - app_arc.lock().await.config.radarr = RadarrConfig { - uri: Some("https://192.168.0.123:8080".to_owned()), - api_token: api_token.clone(), - ..RadarrConfig::default() - }; - let network = Network::new(&app_arc, CancellationToken::new(), Client::new()); - - let request_props = network - .radarr_request_props_from("/test", RequestMethod::Get, None::<()>) - .await; - - assert_str_eq!(request_props.uri, "https://192.168.0.123:8080/api/v3/test"); - assert_eq!(request_props.method, RequestMethod::Get); - assert_eq!(request_props.body, None); - assert_str_eq!(request_props.api_token, api_token); - } - #[test] fn test_get_movie_status_downloaded() { assert_str_eq!(get_movie_status(true, &[], 0), "Downloaded"); @@ -4979,54 +5077,9 @@ mod test { ); } - async fn mock_radarr_api( - method: RequestMethod, - request_body: Option, - response_body: Option, - response_status: Option, - resource: &str, - ) -> (Mock, Arc>>, ServerGuard) { - let status = response_status.unwrap_or(200); - let mut server = Server::new_async().await; - let mut async_server = server - .mock( - &method.to_string().to_uppercase(), - format!("/api/v3{resource}").as_str(), - ) - .match_header("X-Api-Key", "test1234") - .with_status(status); - - if let Some(body) = request_body { - async_server = async_server.match_body(Matcher::Json(body)); - } - - if let Some(body) = response_body { - async_server = async_server.with_body(body.to_string()); - } - - async_server = async_server.create_async().await; - - let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); - let port = Some( - server.host_with_port().split(':').collect::>()[1] - .parse() - .unwrap(), - ); - let mut app = App::default(); - let radarr_config = RadarrConfig { - host, - port, - api_token: "test1234".to_owned(), - ..RadarrConfig::default() - }; - app.config.radarr = radarr_config; - let app_arc = Arc::new(Mutex::new(app)); - - (async_server, app_arc, server) - } - fn language() -> Language { Language { + id: 1, name: "English".to_owned(), } } @@ -5172,8 +5225,8 @@ mod test { QualityWrapper { quality: quality() } } - fn release() -> Release { - Release { + fn release() -> RadarrRelease { + RadarrRelease { guid: "1234".to_owned(), protocol: "torrent".to_owned(), age: 1, diff --git a/src/network/sonarr_network.rs b/src/network/sonarr_network.rs new file mode 100644 index 0000000..6a0d84a --- /dev/null +++ b/src/network/sonarr_network.rs @@ -0,0 +1,2528 @@ +use anyhow::{anyhow, Result}; +use indoc::formatdoc; +use log::{debug, info, warn}; +use serde_json::{json, Value}; +use urlencoding::encode; + +use crate::{ + models::{ + radarr_models::IndexerTestResult, + servarr_data::{ + modals::{EditIndexerModal, IndexerTestResultModalItem}, + sonarr::{ + modals::{AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal}, + sonarr_data::ActiveSonarrBlock, + }, + }, + servarr_models::{ + AddRootFolderBody, CommandBody, DiskSpace, EditIndexerParams, HostConfig, Indexer, Language, + LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + }, + sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, BlocklistResponse, + DeleteSeriesParams, DownloadRecord, DownloadsResponse, EditSeriesParams, Episode, + IndexerSettings, Series, SonarrCommandBody, SonarrHistoryItem, SonarrHistoryWrapper, + SonarrRelease, SonarrReleaseDownloadBody, SonarrSerdeable, SonarrTask, SonarrTaskName, + SystemStatus, + }, + stateful_table::StatefulTable, + HorizontallyScrollableText, Route, Scrollable, ScrollableText, + }, + network::RequestMethod, + utils::convert_to_gb, +}; + +use super::{Network, NetworkEvent, NetworkResource}; +#[cfg(test)] +#[path = "sonarr_network_tests.rs"] +mod sonarr_network_tests; + +#[derive(Debug, Eq, PartialEq, Clone)] +pub enum SonarrEvent { + AddRootFolder(Option), + AddSeries(Option), + AddTag(String), + ClearBlocklist, + DeleteBlocklistItem(Option), + DeleteDownload(Option), + DeleteEpisodeFile(Option), + DeleteIndexer(Option), + DeleteRootFolder(Option), + DeleteSeries(Option), + DeleteTag(i64), + DownloadRelease(SonarrReleaseDownloadBody), + EditAllIndexerSettings(Option), + EditIndexer(Option), + EditSeries(Option), + GetAllIndexerSettings, + GetBlocklist, + GetDownloads, + GetHistory(Option), + GetHostConfig, + GetIndexers, + GetEpisodeDetails(Option), + GetEpisodes(Option), + GetEpisodeHistory(Option), + GetLanguageProfiles, + GetLogs(Option), + GetDiskSpace, + GetQualityProfiles, + GetQueuedEvents, + GetRootFolders, + GetEpisodeReleases(Option), + GetSeasonReleases(Option<(i64, i64)>), + GetSecurityConfig, + GetSeriesDetails(Option), + GetSeriesHistory(Option), + GetStatus, + GetUpdates, + GetTags, + GetTasks, + HealthCheck, + ListSeries, + MarkHistoryItemAsFailed(i64), + SearchNewSeries(Option), + StartTask(Option), + TestIndexer(Option), + TestAllIndexers, + TriggerAutomaticEpisodeSearch(Option), + TriggerAutomaticSeasonSearch(Option<(i64, i64)>), + TriggerAutomaticSeriesSearch(Option), + UpdateAllSeries, + UpdateAndScanSeries(Option), + UpdateDownloads, +} + +impl NetworkResource for SonarrEvent { + fn resource(&self) -> &'static str { + match &self { + SonarrEvent::AddTag(_) | SonarrEvent::DeleteTag(_) | SonarrEvent::GetTags => "/tag", + SonarrEvent::ClearBlocklist => "/blocklist/bulk", + SonarrEvent::DownloadRelease(_) => "/release", + SonarrEvent::DeleteBlocklistItem(_) => "/blocklist", + SonarrEvent::GetAllIndexerSettings | SonarrEvent::EditAllIndexerSettings(_) => { + "/config/indexer" + } + SonarrEvent::DeleteEpisodeFile(_) => "/episodefile", + SonarrEvent::GetBlocklist => "/blocklist?page=1&pageSize=10000", + SonarrEvent::GetDownloads | SonarrEvent::DeleteDownload(_) => "/queue", + SonarrEvent::GetEpisodes(_) | SonarrEvent::GetEpisodeDetails(_) => "/episode", + SonarrEvent::GetHistory(_) | SonarrEvent::GetEpisodeHistory(_) => "/history", + SonarrEvent::GetHostConfig | SonarrEvent::GetSecurityConfig => "/config/host", + SonarrEvent::GetIndexers | SonarrEvent::DeleteIndexer(_) | SonarrEvent::EditIndexer(_) => { + "/indexer" + } + SonarrEvent::GetLanguageProfiles => "/languageprofile", + SonarrEvent::GetLogs(_) => "/log", + SonarrEvent::GetDiskSpace => "/diskspace", + SonarrEvent::GetQualityProfiles => "/qualityprofile", + SonarrEvent::GetQueuedEvents + | SonarrEvent::StartTask(_) + | SonarrEvent::TriggerAutomaticSeriesSearch(_) + | SonarrEvent::TriggerAutomaticSeasonSearch(_) + | SonarrEvent::TriggerAutomaticEpisodeSearch(_) + | SonarrEvent::UpdateAllSeries + | SonarrEvent::UpdateAndScanSeries(_) + | SonarrEvent::UpdateDownloads => "/command", + SonarrEvent::GetRootFolders + | SonarrEvent::DeleteRootFolder(_) + | SonarrEvent::AddRootFolder(_) => "/rootfolder", + SonarrEvent::GetSeasonReleases(_) | SonarrEvent::GetEpisodeReleases(_) => "/release", + SonarrEvent::GetSeriesHistory(_) => "/history/series", + SonarrEvent::GetStatus => "/system/status", + SonarrEvent::GetTasks => "/system/task", + SonarrEvent::GetUpdates => "/update", + SonarrEvent::HealthCheck => "/health", + SonarrEvent::AddSeries(_) + | SonarrEvent::ListSeries + | SonarrEvent::GetSeriesDetails(_) + | SonarrEvent::DeleteSeries(_) + | SonarrEvent::EditSeries(_) => "/series", + SonarrEvent::SearchNewSeries(_) => "/series/lookup", + SonarrEvent::MarkHistoryItemAsFailed(_) => "/history/failed", + SonarrEvent::TestIndexer(_) => "/indexer/test", + SonarrEvent::TestAllIndexers => "/indexer/testall", + } + } +} + +impl From for NetworkEvent { + fn from(sonarr_event: SonarrEvent) -> Self { + NetworkEvent::Sonarr(sonarr_event) + } +} + +impl<'a, 'b> Network<'a, 'b> { + pub async fn handle_sonarr_event( + &mut self, + sonarr_event: SonarrEvent, + ) -> Result { + match sonarr_event { + SonarrEvent::AddRootFolder(path) => self + .add_sonarr_root_folder(path) + .await + .map(SonarrSerdeable::from), + SonarrEvent::AddSeries(body) => self + .add_sonarr_series(body) + .await + .map(SonarrSerdeable::from), + SonarrEvent::AddTag(tag) => self.add_sonarr_tag(tag).await.map(SonarrSerdeable::from), + SonarrEvent::ClearBlocklist => self + .clear_sonarr_blocklist() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetAllIndexerSettings => self + .get_all_sonarr_indexer_settings() + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteBlocklistItem(blocklist_item_id) => self + .delete_sonarr_blocklist_item(blocklist_item_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteDownload(download_id) => self + .delete_sonarr_download(download_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteEpisodeFile(episode_file_id) => self + .delete_sonarr_episode_file(episode_file_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteIndexer(indexer_id) => self + .delete_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteRootFolder(root_folder_id) => self + .delete_sonarr_root_folder(root_folder_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DeleteSeries(params) => { + self.delete_series(params).await.map(SonarrSerdeable::from) + } + SonarrEvent::DeleteTag(tag_id) => self + .delete_sonarr_tag(tag_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::DownloadRelease(sonarr_release_download_body) => self + .download_sonarr_release(sonarr_release_download_body) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditAllIndexerSettings(params) => self + .edit_all_sonarr_indexer_settings(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditIndexer(params) => self + .edit_sonarr_indexer(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::EditSeries(params) => self + .edit_sonarr_series(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetBlocklist => self.get_sonarr_blocklist().await.map(SonarrSerdeable::from), + SonarrEvent::GetDownloads => self.get_sonarr_downloads().await.map(SonarrSerdeable::from), + SonarrEvent::GetEpisodes(series_id) => self + .get_episodes(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeDetails(episode_id) => self + .get_episode_details(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeHistory(episode_id) => self + .get_sonarr_episode_history(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetHistory(events) => self + .get_sonarr_history(events) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetHostConfig => self + .get_sonarr_host_config() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetIndexers => self.get_sonarr_indexers().await.map(SonarrSerdeable::from), + SonarrEvent::GetLanguageProfiles => self + .get_sonarr_language_profiles() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetLogs(events) => self + .get_sonarr_logs(events) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetDiskSpace => self.get_sonarr_diskspace().await.map(SonarrSerdeable::from), + SonarrEvent::GetQualityProfiles => self + .get_sonarr_quality_profiles() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetQueuedEvents => self + .get_queued_sonarr_events() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetRootFolders => self + .get_sonarr_root_folders() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetEpisodeReleases(params) => self + .get_episode_releases(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeasonReleases(params) => self + .get_season_releases(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSecurityConfig => self + .get_sonarr_security_config() + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesDetails(series_id) => self + .get_series_details(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetSeriesHistory(series_id) => self + .get_sonarr_series_history(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::GetStatus => self.get_sonarr_status().await.map(SonarrSerdeable::from), + SonarrEvent::GetTags => self.get_sonarr_tags().await.map(SonarrSerdeable::from), + SonarrEvent::GetTasks => self.get_sonarr_tasks().await.map(SonarrSerdeable::from), + SonarrEvent::GetUpdates => self.get_sonarr_updates().await.map(SonarrSerdeable::from), + SonarrEvent::HealthCheck => self + .get_sonarr_healthcheck() + .await + .map(SonarrSerdeable::from), + SonarrEvent::ListSeries => self.list_series().await.map(SonarrSerdeable::from), + SonarrEvent::MarkHistoryItemAsFailed(history_item_id) => self + .mark_sonarr_history_item_as_failed(history_item_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::SearchNewSeries(query) => self + .search_sonarr_series(query) + .await + .map(SonarrSerdeable::from), + SonarrEvent::StartTask(task_name) => self + .start_sonarr_task(task_name) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TestIndexer(indexer_id) => self + .test_sonarr_indexer(indexer_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TestAllIndexers => self + .test_all_sonarr_indexers() + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeasonSearch(params) => self + .trigger_automatic_season_search(params) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticSeriesSearch(series_id) => self + .trigger_automatic_series_search(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id) => self + .trigger_automatic_episode_search(episode_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::UpdateAllSeries => self.update_all_series().await.map(SonarrSerdeable::from), + SonarrEvent::UpdateAndScanSeries(series_id) => self + .update_and_scan_series(series_id) + .await + .map(SonarrSerdeable::from), + SonarrEvent::UpdateDownloads => self + .update_sonarr_downloads() + .await + .map(SonarrSerdeable::from), + } + } + + async fn add_sonarr_root_folder(&mut self, root_folder: Option) -> Result { + info!("Adding new root folder to Sonarr"); + let event = SonarrEvent::AddRootFolder(None); + let body = if let Some(path) = root_folder { + AddRootFolderBody { path } + } else { + let mut app = self.app.lock().await; + let path = app + .data + .sonarr_data + .edit_root_folder + .as_ref() + .unwrap() + .text + .clone(); + + app.data.sonarr_data.edit_root_folder = None; + + AddRootFolderBody { path } + }; + + debug!("Add root folder body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn add_sonarr_series( + &mut self, + add_series_body_option: Option, + ) -> Result { + info!("Adding new series to Sonarr"); + let event = SonarrEvent::AddSeries(None); + let body = if let Some(add_series_body) = add_series_body_option { + add_series_body + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .add_series_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + let AddSeriesModal { + root_folder_list, + monitor_list, + quality_profile_list, + language_profile_list, + series_type_list, + use_season_folder, + .. + } = app.data.sonarr_data.add_series_modal.as_ref().unwrap(); + let season_folder = *use_season_folder; + let (tvdb_id, title) = { + let AddSeriesSearchResult { tvdb_id, title, .. } = app + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .clone(); + (tvdb_id, title.text) + }; + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *app + .data + .sonarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let language_profile = language_profile_list.current_selection(); + let language_profile_id = *app + .data + .sonarr_data + .language_profiles_map + .iter() + .filter(|(_, value)| *value == language_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + let path = root_folder_list.current_selection().path.clone(); + let monitor = monitor_list.current_selection().to_string(); + let series_type = series_type_list.current_selection().to_string(); + + app.data.sonarr_data.add_series_modal = None; + + AddSeriesBody { + tvdb_id, + title, + monitored: true, + root_folder_path: path, + quality_profile_id, + language_profile_id, + series_type, + season_folder, + tags: tag_ids_vec, + add_options: AddSeriesOptions { + monitor, + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + } + }; + + debug!("Add series body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn add_sonarr_tag(&mut self, tag: String) -> Result { + info!("Adding a new Sonarr tag"); + let event = SonarrEvent::AddTag(String::new()); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(json!({ "label": tag })), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |tag, mut app| { + app.data.sonarr_data.tags_map.insert(tag.id, tag.label); + }) + .await + } + + async fn clear_sonarr_blocklist(&mut self) -> Result<()> { + info!("Clearing Sonarr blocklist"); + let event = SonarrEvent::ClearBlocklist; + + let ids = self + .app + .lock() + .await + .data + .sonarr_data + .blocklist + .items + .iter() + .map(|item| item.id) + .collect::>(); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + Some(json!({"ids": ids})), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn delete_sonarr_blocklist_item(&mut self, blocklist_item_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteBlocklistItem(None); + let id = if let Some(b_id) = blocklist_item_id { + b_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .blocklist + .current_selection() + .id + }; + + info!("Deleting Sonarr blocklist item for item with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn delete_sonarr_episode_file(&mut self, episode_file_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteEpisodeFile(None); + let id = if let Some(ep_id) = episode_file_id { + ep_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details have not been loaded") + .episodes + .current_selection() + .episode_file_id + }; + + info!("Deleting Sonarr episode file for episode file with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn delete_sonarr_download(&mut self, download_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteDownload(None); + let id = if let Some(dl_id) = download_id { + dl_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .downloads + .current_selection() + .id + }; + + info!("Deleting Sonarr download for download with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn delete_sonarr_indexer(&mut self, indexer_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteIndexer(None); + let id = if let Some(i_id) = indexer_id { + i_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + + info!("Deleting Sonarr indexer for indexer with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn delete_sonarr_root_folder(&mut self, root_folder_id: Option) -> Result<()> { + let event = SonarrEvent::DeleteRootFolder(None); + let id = if let Some(rf_id) = root_folder_id { + rf_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .root_folders + .current_selection() + .id + }; + + info!("Deleting Sonarr root folder for folder with id: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn delete_series( + &mut self, + delete_series_params: Option, + ) -> Result<()> { + let event = SonarrEvent::DeleteSeries(None); + let (series_id, delete_files, add_import_exclusion) = if let Some(params) = delete_series_params + { + ( + params.id, + params.delete_series_files, + params.add_list_exclusion, + ) + } else { + let (series_id, _) = self.extract_series_id(None).await; + let delete_files = self.app.lock().await.data.sonarr_data.delete_series_files; + let add_import_exclusion = self.app.lock().await.data.sonarr_data.add_list_exclusion; + + (series_id, delete_files, add_import_exclusion) + }; + + info!("Deleting Sonarr series with ID: {series_id} with deleteFiles={delete_files} and addImportExclusion={add_import_exclusion}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{series_id}")), + Some(format!( + "deleteFiles={delete_files}&addImportExclusion={add_import_exclusion}" + )), + ) + .await; + + let resp = self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await; + + self + .app + .lock() + .await + .data + .sonarr_data + .reset_delete_series_preferences(); + + resp + } + + async fn delete_sonarr_tag(&mut self, id: i64) -> Result<()> { + info!("Deleting Sonarr tag with id: {id}"); + let event = SonarrEvent::DeleteTag(id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Delete, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn download_sonarr_release( + &mut self, + sonarr_release_download_body: SonarrReleaseDownloadBody, + ) -> Result { + let event = SonarrEvent::DownloadRelease(sonarr_release_download_body.clone()); + info!("Downloading Sonarr release with params: {sonarr_release_download_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + Some(sonarr_release_download_body), + None, + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn edit_all_sonarr_indexer_settings( + &mut self, + params: Option, + ) -> Result { + info!("Updating Sonarr indexer settings"); + let event = SonarrEvent::EditAllIndexerSettings(None); + + let body = if let Some(indexer_settings) = params { + indexer_settings + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexer_settings + .as_ref() + .unwrap() + .clone() + }; + + debug!("Indexer settings body: {body:?}"); + + let request_props = self + .request_props_from(event, RequestMethod::Put, Some(body), None, None) + .await; + + let resp = self + .handle_request::(request_props, |_, _| {}) + .await; + + self.app.lock().await.data.sonarr_data.indexer_settings = None; + + resp + } + + async fn edit_sonarr_indexer( + &mut self, + edit_indexer_params: Option, + ) -> Result<()> { + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::EditIndexer(None); + let id = if let Some(ref params) = edit_indexer_params { + params.indexer_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + info!("Updating Sonarr indexer with ID: {id}"); + + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + response = detailed_indexer_body.to_string() + }) + .await?; + + info!("Constructing edit indexer body"); + + let mut detailed_indexer_body: Value = serde_json::from_str(&response).unwrap(); + let priority = detailed_indexer_body["priority"] + .as_i64() + .expect("Unable to deserialize 'priority'"); + + let ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) = if let Some(params) = edit_indexer_params { + let seed_ratio_field_option = detailed_indexer_body["fields"] + .as_array() + .unwrap() + .iter() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + let name = params.name.unwrap_or( + detailed_indexer_body["name"] + .as_str() + .expect("Unable to deserialize 'name'") + .to_owned(), + ); + let enable_rss = params.enable_rss.unwrap_or( + detailed_indexer_body["enableRss"] + .as_bool() + .expect("Unable to deserialize 'enableRss'"), + ); + let enable_automatic_search = params.enable_automatic_search.unwrap_or( + detailed_indexer_body["enableAutomaticSearch"] + .as_bool() + .expect("Unable to deserialize 'enableAutomaticSearch"), + ); + let enable_interactive_search = params.enable_interactive_search.unwrap_or( + detailed_indexer_body["enableInteractiveSearch"] + .as_bool() + .expect("Unable to deserialize 'enableInteractiveSearch'"), + ); + let url = params.url.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "baseUrl") + .expect("Field 'baseUrl' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'baseUrl value'") + .to_owned(), + ); + let api_key = params.api_key.unwrap_or( + detailed_indexer_body["fields"] + .as_array() + .expect("Unable to deserialize 'fields'") + .iter() + .find(|field| field["name"] == "apiKey") + .expect("Field 'apiKey' was not found in the 'fields' array") + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'apiKey value'") + .to_owned(), + ); + let seed_ratio = params.seed_ratio.unwrap_or_else(|| { + if let Some(seed_ratio_field) = seed_ratio_field_option { + return seed_ratio_field + .get("value") + .unwrap_or(&json!("")) + .as_str() + .expect("Unable to deserialize 'seedCriteria.seedRatio value'") + .to_owned(); + } + + String::new() + }); + let tags = if params.clear_tags { + vec![] + } else { + params.tags.unwrap_or( + detailed_indexer_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + let priority = params.priority.unwrap_or(priority); + + ( + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + tags, + priority, + ) + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .edit_indexer_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + + let params = { + let EditIndexerModal { + name, + enable_rss, + enable_automatic_search, + enable_interactive_search, + url, + api_key, + seed_ratio, + .. + } = app.data.sonarr_data.edit_indexer_modal.as_ref().unwrap(); + + ( + name.text.clone(), + enable_rss.unwrap_or_default(), + enable_automatic_search.unwrap_or_default(), + enable_interactive_search.unwrap_or_default(), + url.text.clone(), + api_key.text.clone(), + seed_ratio.text.clone(), + tag_ids_vec, + priority, + ) + }; + + app.data.sonarr_data.edit_indexer_modal = None; + + params + }; + + *detailed_indexer_body.get_mut("name").unwrap() = json!(name); + *detailed_indexer_body.get_mut("priority").unwrap() = json!(priority); + *detailed_indexer_body.get_mut("enableRss").unwrap() = json!(enable_rss); + *detailed_indexer_body + .get_mut("enableAutomaticSearch") + .unwrap() = json!(enable_automatic_search); + *detailed_indexer_body + .get_mut("enableInteractiveSearch") + .unwrap() = json!(enable_interactive_search); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "baseUrl") + .unwrap() + .get_mut("value") + .unwrap() = json!(url); + *detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "apiKey") + .unwrap() + .get_mut("value") + .unwrap() = json!(api_key); + *detailed_indexer_body.get_mut("tags").unwrap() = json!(tags); + let seed_ratio_field_option = detailed_indexer_body + .get_mut("fields") + .unwrap() + .as_array_mut() + .unwrap() + .iter_mut() + .find(|field| field["name"] == "seedCriteria.seedRatio"); + if let Some(seed_ratio_field) = seed_ratio_field_option { + seed_ratio_field + .as_object_mut() + .unwrap() + .insert("value".to_string(), json!(seed_ratio)); + } + + debug!("Edit indexer body: {detailed_indexer_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_indexer_body), + Some(format!("/{id}")), + Some("forceSave=true".to_owned()), + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn edit_sonarr_series( + &mut self, + edit_series_params: Option, + ) -> Result<()> { + info!("Editing Sonarr series"); + let detail_event = SonarrEvent::GetSeriesDetails(None); + let event = SonarrEvent::EditSeries(None); + + let (series_id, _) = if let Some(ref params) = edit_series_params { + self.extract_series_id(Some(params.series_id)).await + } else { + self.extract_series_id(None).await + }; + info!("Fetching series details for series with ID: {series_id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{series_id}")), + None, + ) + .await; + + let mut response = String::new(); + + self + .handle_request::<(), Value>(request_props, |detailed_series_body, _| { + response = detailed_series_body.to_string() + }) + .await?; + + info!("Constructing edit series body"); + + let mut detailed_series_body: Value = serde_json::from_str(&response).unwrap(); + let ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) = if let Some(params) = edit_series_params { + let monitored = params.monitored.unwrap_or( + detailed_series_body["monitored"] + .as_bool() + .expect("Unable to deserialize 'monitored'"), + ); + let use_season_folders = params.use_season_folders.unwrap_or( + detailed_series_body["seasonFolder"] + .as_bool() + .expect("Unable to deserialize 'season_folder'"), + ); + let series_type = params + .series_type + .unwrap_or_else(|| { + serde_json::from_value(detailed_series_body["seriesType"].clone()) + .expect("Unable to deserialize 'seriesType'") + }) + .to_string(); + let quality_profile_id = params.quality_profile_id.unwrap_or_else(|| { + detailed_series_body["qualityProfileId"] + .as_i64() + .expect("Unable to deserialize 'qualityProfileId'") + }); + let language_profile_id = params.language_profile_id.unwrap_or_else(|| { + detailed_series_body["languageProfileId"] + .as_i64() + .expect("Unable to deserialize 'languageProfileId'") + }); + let root_folder_path = params.root_folder_path.unwrap_or_else(|| { + detailed_series_body["path"] + .as_str() + .expect("Unable to deserialize 'path'") + .to_owned() + }); + let tags = if params.clear_tags { + vec![] + } else { + params.tags.unwrap_or( + detailed_series_body["tags"] + .as_array() + .expect("Unable to deserialize 'tags'") + .iter() + .map(|item| item.as_i64().expect("Unable to deserialize tag ID")) + .collect(), + ) + }; + + ( + monitored, + use_season_folders, + series_type, + quality_profile_id, + language_profile_id, + root_folder_path, + tags, + ) + } else { + let tags = self + .app + .lock() + .await + .data + .sonarr_data + .edit_series_modal + .as_ref() + .unwrap() + .tags + .text + .clone(); + let tag_ids_vec = self.extract_and_add_sonarr_tag_ids_vec(tags).await; + let mut app = self.app.lock().await; + + let params = { + let EditSeriesModal { + monitored, + use_season_folders, + path, + series_type_list, + quality_profile_list, + language_profile_list, + .. + } = app.data.sonarr_data.edit_series_modal.as_ref().unwrap(); + let quality_profile = quality_profile_list.current_selection(); + let quality_profile_id = *app + .data + .sonarr_data + .quality_profile_map + .iter() + .filter(|(_, value)| *value == quality_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + let language_profile = language_profile_list.current_selection(); + let language_profile_id = *app + .data + .sonarr_data + .language_profiles_map + .iter() + .filter(|(_, value)| *value == language_profile) + .map(|(key, _)| key) + .next() + .unwrap(); + + ( + monitored.unwrap_or_default(), + use_season_folders.unwrap_or_default(), + series_type_list.current_selection().to_string(), + quality_profile_id, + language_profile_id, + path.text.clone(), + tag_ids_vec, + ) + }; + + app.data.sonarr_data.edit_series_modal = None; + + params + }; + + *detailed_series_body.get_mut("monitored").unwrap() = json!(monitored); + *detailed_series_body.get_mut("seasonFolder").unwrap() = json!(use_season_folders); + *detailed_series_body.get_mut("seriesType").unwrap() = json!(series_type); + *detailed_series_body.get_mut("qualityProfileId").unwrap() = json!(quality_profile_id); + *detailed_series_body.get_mut("languageProfileId").unwrap() = json!(language_profile_id); + *detailed_series_body.get_mut("path").unwrap() = json!(root_folder_path); + *detailed_series_body.get_mut("tags").unwrap() = json!(tags); + + debug!("Edit series body: {detailed_series_body:?}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Put, + Some(detailed_series_body), + Some(format!("/{series_id}")), + None, + ) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn get_all_sonarr_indexer_settings(&mut self) -> Result { + info!("Fetching Sonarr indexer settings"); + let event = SonarrEvent::GetAllIndexerSettings; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), IndexerSettings>(request_props, |indexer_settings, mut app| { + if app.data.sonarr_data.indexer_settings.is_none() { + app.data.sonarr_data.indexer_settings = Some(indexer_settings); + } else { + debug!("Indexer Settings are being modified. Ignoring update..."); + } + }) + .await + } + + async fn get_sonarr_healthcheck(&mut self) -> Result<()> { + info!("Performing Sonarr health check"); + let event = SonarrEvent::HealthCheck; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), ()>(request_props, |_, _| ()) + .await + } + + async fn get_sonarr_blocklist(&mut self) -> Result { + info!("Fetching Sonarr blocklist"); + let event = SonarrEvent::GetBlocklist; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), BlocklistResponse>(request_props, |blocklist_resp, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::BlocklistSortPrompt, _) + ) { + let mut blocklist_vec = blocklist_resp.records; + blocklist_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.blocklist.set_items(blocklist_vec); + app.data.sonarr_data.blocklist.apply_sorting_toggle(false); + } + }) + .await + } + + async fn get_sonarr_downloads(&mut self) -> Result { + info!("Fetching Sonarr downloads"); + let event = SonarrEvent::GetDownloads; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), DownloadsResponse>(request_props, |queue_response, mut app| { + app + .data + .sonarr_data + .downloads + .set_items(queue_response.records); + }) + .await + } + + async fn get_episodes(&mut self, series_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodes(series_id); + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching episodes for Sonarr series with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut episode_vec, mut app| { + episode_vec.sort_by(|a, b| a.id.cmp(&b.id)); + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::EpisodesSortPrompt, _) + ) { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(episode_vec.clone()); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .apply_sorting_toggle(false); + } + }) + .await + } + + async fn get_sonarr_episode_history( + &mut self, + episode_id: Option, + ) -> Result { + let id = self.extract_episode_id(episode_id).await; + info!("Fetching Sonarr history for episode with ID: {id}"); + let event = SonarrEvent::GetEpisodeHistory(episode_id); + + let params = format!("episodeId={id}&pageSize=1000&sortDirection=descending&sortKey=date",); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .set_items(history_vec); + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .apply_sorting_toggle(false); + }) + .await + } + + async fn get_episode_details(&mut self, episode_id: Option) -> Result { + info!("Fetching Sonarr episode details"); + let event = SonarrEvent::GetEpisodeDetails(None); + let id = self.extract_episode_id(episode_id).await; + + info!("Fetching episode details for episode with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), Episode>(request_props, |episode_response, mut app| { + let Episode { + id, + title, + air_date_utc, + overview, + has_file, + season_number, + episode_number, + episode_file, + .. + } = episode_response; + let status = get_episode_status(has_file, &app.data.sonarr_data.downloads.items, id); + let air_date = if let Some(air_date) = air_date_utc { + format!("{air_date}") + } else { + String::new() + }; + let mut episode_details_modal = EpisodeDetailsModal { + episode_details: ScrollableText::with_string(formatdoc!( + " + Title: {} + Season: {season_number} + Episode Number: {episode_number} + Air Date: {air_date} + Status: {status} + Description: {}", + title.unwrap_or_default(), + overview.unwrap_or_default(), + )), + ..EpisodeDetailsModal::default() + }; + if let Some(file) = episode_file { + let size = convert_to_gb(file.size); + episode_details_modal.file_details = formatdoc!( + " + Relative Path: {} + Absolute Path: {} + Size: {size:.2} GB + Language: {} + Date Added: {}", + file.relative_path, + file.path, + file.language.name, + file.date_added, + ); + + if let Some(media_info) = file.media_info { + episode_details_modal.audio_details = formatdoc!( + " + Bitrate: {} + Channels: {:.1} + Codec: {} + Languages: {} + Stream Count: {}", + media_info.audio_bitrate, + media_info.audio_channels.as_f64().unwrap(), + media_info.audio_codec.unwrap_or_default(), + media_info.audio_languages.unwrap_or_default(), + media_info.audio_stream_count + ); + + episode_details_modal.video_details = formatdoc!( + " + Bit Depth: {} + Bitrate: {} + Codec: {} + FPS: {} + Resolution: {} + Scan Type: {} + Runtime: {} + Subtitles: {}", + media_info.video_bit_depth, + media_info.video_bitrate, + media_info.video_codec, + media_info.video_fps.as_f64().unwrap(), + media_info.resolution, + media_info.scan_type, + media_info.run_time, + media_info.subtitles.unwrap_or_default() + ); + } + }; + + if !app.cli_mode { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .expect("Season details modal is empty") + .episode_details_modal = Some(episode_details_modal); + } + }) + .await + } + + async fn get_sonarr_host_config(&mut self) -> Result { + info!("Fetching Sonarr host config"); + let event = SonarrEvent::GetHostConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), HostConfig>(request_props, |_, _| ()) + .await + } + + async fn get_sonarr_history(&mut self, events: Option) -> Result { + info!("Fetching all Sonarr history events"); + let event = SonarrEvent::GetHistory(events); + + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=date", + events.unwrap_or(500) + ); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), SonarrHistoryWrapper>(request_props, |history_response, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::HistorySortPrompt, _) + ) { + let mut history_vec = history_response.records; + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.history.set_items(history_vec); + app.data.sonarr_data.history.apply_sorting_toggle(false); + } + }) + .await + } + + async fn get_sonarr_indexers(&mut self) -> Result> { + info!("Fetching Sonarr indexers"); + let event = SonarrEvent::GetIndexers; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |indexers, mut app| { + app.data.sonarr_data.indexers.set_items(indexers); + }) + .await + } + + async fn get_sonarr_language_profiles(&mut self) -> Result> { + info!("Fetching Sonarr language profiles"); + let event = SonarrEvent::GetLanguageProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |language_profiles_vec, mut app| { + app.data.sonarr_data.language_profiles_map = language_profiles_vec + .into_iter() + .map(|language| (language.id, language.name)) + .collect(); + }) + .await + } + + async fn get_sonarr_logs(&mut self, events: Option) -> Result { + info!("Fetching Sonarr logs"); + let event = SonarrEvent::GetLogs(events); + + let params = format!( + "pageSize={}&sortDirection=descending&sortKey=time", + events.unwrap_or(500) + ); + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, Some(params)) + .await; + + self + .handle_request::<(), LogResponse>(request_props, |log_response, mut app| { + let mut logs = log_response.records; + logs.reverse(); + + let log_lines = logs + .into_iter() + .map(|log| { + if log.exception.is_some() { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.exception_type.as_ref().unwrap(), + log.exception.as_ref().unwrap() + )) + } else { + HorizontallyScrollableText::from(format!( + "{}|{}|{}|{}", + log.time, + log.level.to_uppercase(), + log.logger.as_ref().unwrap(), + log.message.as_ref().unwrap() + )) + } + }) + .collect(); + + app.data.sonarr_data.logs.set_items(log_lines); + app.data.sonarr_data.logs.scroll_to_bottom(); + }) + .await + } + + async fn get_sonarr_diskspace(&mut self) -> Result> { + info!("Fetching Sonarr disk space"); + let event = SonarrEvent::GetDiskSpace; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |disk_space_vec, mut app| { + app.data.sonarr_data.disk_space_vec = disk_space_vec; + }) + .await + } + + async fn get_sonarr_quality_profiles(&mut self) -> Result> { + info!("Fetching Sonarr quality profiles"); + let event = SonarrEvent::GetQualityProfiles; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |quality_profiles, mut app| { + app.data.sonarr_data.quality_profile_map = quality_profiles + .into_iter() + .map(|profile| (profile.id, profile.name)) + .collect(); + }) + .await + } + + async fn get_queued_sonarr_events(&mut self) -> Result> { + info!("Fetching Sonarr queued events"); + let event = SonarrEvent::GetQueuedEvents; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |queued_events_vec, mut app| { + app + .data + .sonarr_data + .queued_events + .set_items(queued_events_vec); + }) + .await + } + + async fn get_sonarr_root_folders(&mut self) -> Result> { + info!("Fetching Sonarr root folders"); + let event = SonarrEvent::GetRootFolders; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |root_folders, mut app| { + app.data.sonarr_data.root_folders.set_items(root_folders); + }) + .await + } + + async fn get_episode_releases(&mut self, episode_id: Option) -> Result> { + let event = SonarrEvent::GetEpisodeReleases(None); + let id = self.extract_episode_id(episode_id).await; + + info!("Fetching releases for episode with ID: {id}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("episodeId={id}")), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + if app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .is_none() + { + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + } + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_releases + .set_items(release_vec); + }) + .await + } + + async fn get_season_releases( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result> { + let event = SonarrEvent::GetSeasonReleases(None); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, series_id_param) = self.extract_series_id(series_id).await; + let (season_number, season_number_param) = self.extract_season_number(season_number).await; + + info!("Fetching releases for series with ID: {series_id} and season number: {season_number}"); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("{}&{}", series_id_param, season_number_param)), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |release_vec, mut app| { + if app.data.sonarr_data.season_details_modal.is_none() { + app.data.sonarr_data.season_details_modal = Some(SeasonDetailsModal::default()); + } + + let season_releases_vec = release_vec + .into_iter() + .filter(|release| release.full_season) + .collect(); + + app + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .season_releases + .set_items(season_releases_vec); + }) + .await + } + + async fn get_sonarr_security_config(&mut self) -> Result { + info!("Fetching Sonarr security config"); + let event = SonarrEvent::GetSecurityConfig; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SecurityConfig>(request_props, |_, _| ()) + .await + } + + async fn get_series_details(&mut self, series_id: Option) -> Result { + let (id, _) = self.extract_series_id(series_id).await; + info!("Fetching details for Sonarr series with ID: {id}"); + let event = SonarrEvent::GetSeriesDetails(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + self + .handle_request::<(), Series>(request_props, |_, _| ()) + .await + } + + async fn get_sonarr_series_history( + &mut self, + series_id: Option, + ) -> Result> { + let (id, series_id_param) = self.extract_series_id(series_id).await; + info!("Fetching Sonarr series history for series with ID: {id}"); + let event = SonarrEvent::GetSeriesHistory(series_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(series_id_param), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut history_vec, mut app| { + if app.data.sonarr_data.series_history.is_none() { + app.data.sonarr_data.series_history = Some(StatefulTable::default()); + } + + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesHistorySortPrompt, _) + ) { + history_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .set_items(history_vec); + app + .data + .sonarr_data + .series_history + .as_mut() + .unwrap() + .apply_sorting_toggle(false); + } + }) + .await + } + + async fn list_series(&mut self) -> Result> { + info!("Fetching Sonarr library"); + let event = SonarrEvent::ListSeries; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |mut series_vec, mut app| { + if !matches!( + app.get_current_route(), + Route::Sonarr(ActiveSonarrBlock::SeriesSortPrompt, _) + ) { + series_vec.sort_by(|a, b| a.id.cmp(&b.id)); + app.data.sonarr_data.series.set_items(series_vec); + app.data.sonarr_data.series.apply_sorting_toggle(false); + } + }) + .await + } + + async fn get_sonarr_status(&mut self) -> Result { + info!("Fetching Sonarr system status"); + let event = SonarrEvent::GetStatus; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), SystemStatus>(request_props, |system_status, mut app| { + app.data.sonarr_data.version = system_status.version; + app.data.sonarr_data.start_time = system_status.start_time; + }) + .await + } + + async fn get_sonarr_tags(&mut self) -> Result> { + info!("Fetching Sonarr tags"); + let event = SonarrEvent::GetTags; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tags_vec, mut app| { + app.data.sonarr_data.tags_map = tags_vec + .into_iter() + .map(|tag| (tag.id, tag.label)) + .collect(); + }) + .await + } + + async fn get_sonarr_tasks(&mut self) -> Result> { + info!("Fetching Sonarr tasks"); + let event = SonarrEvent::GetTasks; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |tasks_vec, mut app| { + app.data.sonarr_data.tasks.set_items(tasks_vec); + }) + .await + } + + async fn get_sonarr_updates(&mut self) -> Result> { + info!("Fetching Sonarr updates"); + let event = SonarrEvent::GetUpdates; + + let request_props = self + .request_props_from(event, RequestMethod::Get, None::<()>, None, None) + .await; + + self + .handle_request::<(), Vec>(request_props, |updates_vec, mut app| { + let latest_installed = if updates_vec + .iter() + .any(|update| update.latest && update.installed_on.is_some()) + { + "already".to_owned() + } else { + "not".to_owned() + }; + let updates = updates_vec + .into_iter() + .map(|update| { + let install_status = if update.installed_on.is_some() { + if update.installed { + "(Currently Installed)".to_owned() + } else { + "(Previously Installed)".to_owned() + } + } else { + String::new() + }; + let vec_to_bullet_points = |vec: Vec| { + vec + .iter() + .map(|change| format!(" * {change}")) + .collect::>() + .join("\n") + }; + + let mut update_info = formatdoc!( + "{} - {} {install_status} + {}", + update.version, + update.release_date, + "-".repeat(200) + ); + + if let Some(new_changes) = update.changes.new { + let changes = vec_to_bullet_points(new_changes); + update_info = formatdoc!( + "{update_info} + New: + {changes}" + ) + } + + if let Some(fixes) = update.changes.fixed { + let fixes = vec_to_bullet_points(fixes); + update_info = formatdoc!( + "{update_info} + Fixed: + {fixes}" + ); + } + + update_info + }) + .reduce(|version_1, version_2| format!("{version_1}\n\n\n{version_2}")) + .unwrap(); + + app.data.sonarr_data.updates = ScrollableText::with_string(formatdoc!( + "The latest version of Sonarr is {latest_installed} installed + + {updates}" + )); + }) + .await + } + + async fn mark_sonarr_history_item_as_failed(&mut self, history_item_id: i64) -> Result { + info!("Marking the Sonarr history item with ID: {history_item_id} as 'failed'"); + let event = SonarrEvent::MarkHistoryItemAsFailed(history_item_id); + + let request_props = self + .request_props_from( + event, + RequestMethod::Post, + None, + Some(format!("/{history_item_id}")), + None, + ) + .await; + + self + .handle_request::<(), Value>(request_props, |_, _| ()) + .await + } + + async fn search_sonarr_series( + &mut self, + query: Option, + ) -> Result> { + info!("Searching for specific Sonarr series"); + let event = SonarrEvent::SearchNewSeries(None); + let search = if let Some(search_query) = query { + Ok(search_query.into()) + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .add_series_search + .clone() + .ok_or(anyhow!("Encountered a race condition")) + }; + + match search { + Ok(search_string) => { + let request_props = self + .request_props_from( + event, + RequestMethod::Get, + None::<()>, + None, + Some(format!("term={}", encode(&search_string.text))), + ) + .await; + + self + .handle_request::<(), Vec>(request_props, |series_vec, mut app| { + if series_vec.is_empty() { + app.pop_and_push_navigation_stack( + ActiveSonarrBlock::AddSeriesEmptySearchResults.into(), + ); + } else if let Some(add_searched_seriess) = + app.data.sonarr_data.add_searched_series.as_mut() + { + add_searched_seriess.set_items(series_vec); + } else { + let mut add_searched_seriess = StatefulTable::default(); + add_searched_seriess.set_items(series_vec); + app.data.sonarr_data.add_searched_series = Some(add_searched_seriess); + } + }) + .await + } + Err(e) => { + warn!( + "Encountered a race condition: {e}\n \ + This is most likely caused by the user trying to navigate between modals rapidly. \ + Ignoring search request." + ); + Ok(Vec::default()) + } + } + } + + async fn start_sonarr_task(&mut self, task: Option) -> Result { + let event = SonarrEvent::StartTask(None); + let task_name = if let Some(t_name) = task { + t_name + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .tasks + .current_selection() + .task_name + } + .to_string(); + + info!("Starting Sonarr task: {task_name}"); + + let body = CommandBody { name: task_name }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn test_sonarr_indexer(&mut self, indexer_id: Option) -> Result { + let detail_event = SonarrEvent::GetIndexers; + let event = SonarrEvent::TestIndexer(None); + let id = if let Some(i_id) = indexer_id { + i_id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .indexers + .current_selection() + .id + }; + info!("Testing Sonarr indexer with ID: {id}"); + + info!("Fetching indexer details for indexer with ID: {id}"); + + let request_props = self + .request_props_from( + detail_event, + RequestMethod::Get, + None::<()>, + Some(format!("/{id}")), + None, + ) + .await; + + let mut test_body: Value = Value::default(); + + self + .handle_request::<(), Value>(request_props, |detailed_indexer_body, _| { + test_body = detailed_indexer_body; + }) + .await?; + + info!("Testing indexer"); + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, Some(test_body), None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::(request_props, |test_results, mut app| { + if test_results.as_object().is_none() { + app.data.sonarr_data.indexer_test_error = Some( + test_results.as_array().unwrap()[0] + .get("errorMessage") + .unwrap() + .to_string(), + ); + }; + }) + .await + } + + async fn test_all_sonarr_indexers(&mut self) -> Result> { + info!("Testing all Sonarr indexers"); + let event = SonarrEvent::TestAllIndexers; + + let mut request_props = self + .request_props_from(event, RequestMethod::Post, None, None, None) + .await; + request_props.ignore_status_code = true; + + self + .handle_request::<(), Vec>(request_props, |test_results, mut app| { + let mut test_all_indexer_results = StatefulTable::default(); + let indexers = app.data.sonarr_data.indexers.items.clone(); + let modal_test_results = test_results + .iter() + .map(|result| { + let name = indexers + .iter() + .filter(|&indexer| indexer.id == result.id) + .map(|indexer| indexer.name.clone()) + .nth(0) + .unwrap_or_default(); + let validation_failures = result + .validation_failures + .iter() + .map(|failure| { + format!( + "Failure for field '{}': {}", + failure.property_name, failure.error_message + ) + }) + .collect::>() + .join(", "); + + IndexerTestResultModalItem { + name: name.unwrap_or_default(), + is_valid: result.is_valid, + validation_failures: validation_failures.into(), + } + }) + .collect(); + test_all_indexer_results.set_items(modal_test_results); + app.data.sonarr_data.indexer_test_all_results = Some(test_all_indexer_results); + }) + .await + } + + async fn trigger_automatic_series_search(&mut self, series_id: Option) -> Result { + let event = SonarrEvent::TriggerAutomaticSeriesSearch(series_id); + let (id, _) = self.extract_series_id(series_id).await; + info!("Searching indexers for series with ID: {id}"); + + let body = SonarrCommandBody { + name: "SeriesSearch".to_owned(), + series_id: Some(id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn trigger_automatic_season_search( + &mut self, + series_season_id_tuple: Option<(i64, i64)>, + ) -> Result { + let event = SonarrEvent::TriggerAutomaticSeasonSearch(series_season_id_tuple); + let (series_id, season_number) = + if let Some((series_id, season_number)) = series_season_id_tuple { + (Some(series_id), Some(season_number)) + } else { + (None, None) + }; + + let (series_id, _) = self.extract_series_id(series_id).await; + let (season_number, _) = self.extract_season_number(season_number).await; + info!("Searching indexers for series with ID: {series_id} and season number: {season_number}"); + + let body = SonarrCommandBody { + name: "SeasonSearch".to_owned(), + season_number: Some(season_number), + series_id: Some(series_id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn trigger_automatic_episode_search(&mut self, episode_id: Option) -> Result { + let event = SonarrEvent::TriggerAutomaticEpisodeSearch(episode_id); + let id = self.extract_episode_id(episode_id).await; + info!("Searching indexers for episode with ID: {id}"); + + let body = SonarrCommandBody { + name: "EpisodeSearch".to_owned(), + episode_ids: Some(vec![id]), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn update_all_series(&mut self) -> Result { + info!("Updating all series"); + let event = SonarrEvent::UpdateAllSeries; + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn update_and_scan_series(&mut self, series_id: Option) -> Result { + let (id, _) = self.extract_series_id(series_id).await; + let event = SonarrEvent::UpdateAndScanSeries(None); + info!("Updating and scanning series with ID: {id}"); + let body = SonarrCommandBody { + name: "RefreshSeries".to_owned(), + series_id: Some(id), + ..SonarrCommandBody::default() + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn update_sonarr_downloads(&mut self) -> Result { + info!("Updating Sonarr downloads"); + let event = SonarrEvent::UpdateDownloads; + let body = CommandBody { + name: "RefreshMonitoredDownloads".to_owned(), + }; + + let request_props = self + .request_props_from(event, RequestMethod::Post, Some(body), None, None) + .await; + + self + .handle_request::(request_props, |_, _| ()) + .await + } + + async fn extract_and_add_sonarr_tag_ids_vec(&mut self, edit_tags: String) -> Vec { + let tags_map = self.app.lock().await.data.sonarr_data.tags_map.clone(); + let tags = edit_tags.clone(); + let missing_tags_vec = edit_tags + .split(',') + .filter(|&tag| !tag.is_empty() && tags_map.get_by_right(tag.trim()).is_none()) + .collect::>(); + + for tag in missing_tags_vec { + self + .add_sonarr_tag(tag.trim().to_owned()) + .await + .expect("Unable to add tag"); + } + + let app = self.app.lock().await; + tags + .split(',') + .filter(|tag| !tag.is_empty()) + .map(|tag| { + *app + .data + .sonarr_data + .tags_map + .get_by_right(tag.trim()) + .unwrap() + }) + .collect() + } + + async fn extract_series_id(&mut self, series_id: Option) -> (i64, String) { + let series_id = if let Some(id) = series_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .series + .current_selection() + .id + }; + (series_id, format!("seriesId={series_id}")) + } + + async fn extract_season_number(&mut self, season_number: Option) -> (i64, String) { + let season_number = if let Some(number) = season_number { + number + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .seasons + .current_selection() + .season_number + }; + (season_number, format!("seasonNumber={season_number}")) + } + + async fn extract_episode_id(&mut self, episode_id: Option) -> i64 { + let episode_id = if let Some(id) = episode_id { + id + } else { + self + .app + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .expect("Season details have not been loaded") + .episodes + .current_selection() + .id + }; + + episode_id + } +} + +fn get_episode_status(has_file: bool, downloads_vec: &[DownloadRecord], episode_id: i64) -> String { + if !has_file { + if let Some(download) = downloads_vec + .iter() + .find(|&download| download.episode_id == episode_id) + { + if download.status == "downloading" { + return "Downloading".to_owned(); + } + + if download.status == "completed" { + return "Awaiting Import".to_owned(); + } + } + + return "Missing".to_owned(); + } + + "Downloaded".to_owned() +} diff --git a/src/network/sonarr_network_tests.rs b/src/network/sonarr_network_tests.rs new file mode 100644 index 0000000..3b5e058 --- /dev/null +++ b/src/network/sonarr_network_tests.rs @@ -0,0 +1,6848 @@ +#[cfg(test)] +mod test { + use std::sync::Arc; + + use bimap::BiMap; + use chrono::{DateTime, Utc}; + use indoc::formatdoc; + use mockito::{Matcher, Server}; + use pretty_assertions::{assert_eq, assert_str_eq}; + use reqwest::Client; + use rstest::rstest; + use serde_json::json; + use serde_json::{Number, Value}; + use strum::IntoEnumIterator; + use tokio::sync::Mutex; + use tokio_util::sync::CancellationToken; + + use crate::models::sonarr_models::{ + AddSeriesBody, AddSeriesOptions, AddSeriesSearchResult, AddSeriesSearchResultStatistics, + EditSeriesParams, IndexerSettings, SeriesMonitor, + }; + + use crate::app::{App, ServarrConfig}; + use crate::models::radarr_models::IndexerTestResult; + use crate::models::servarr_data::modals::{EditIndexerModal, IndexerTestResultModalItem}; + use crate::models::servarr_data::sonarr::modals::{ + AddSeriesModal, EditSeriesModal, EpisodeDetailsModal, SeasonDetailsModal, + }; + use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock; + use crate::models::servarr_models::{ + DiskSpace, EditIndexerParams, HostConfig, Indexer, IndexerField, Language, LogResponse, + Quality, QualityProfile, QualityWrapper, QueueEvent, RootFolder, SecurityConfig, Tag, Update, + }; + use crate::models::sonarr_models::{ + BlocklistItem, DeleteSeriesParams, DownloadRecord, DownloadsResponse, Episode, EpisodeFile, + MediaInfo, SonarrRelease, SonarrReleaseDownloadBody, SonarrTaskName, + }; + use crate::models::sonarr_models::{ + BlocklistResponse, SonarrHistoryData, SonarrHistoryItem, SonarrHistoryWrapper, + }; + use crate::models::sonarr_models::{SonarrTask, SystemStatus}; + use crate::models::stateful_table::StatefulTable; + use crate::models::{sonarr_models::SonarrSerdeable, stateful_table::SortOption}; + use crate::models::{HorizontallyScrollableText, Scrollable, ScrollableText}; + + use crate::network::sonarr_network::get_episode_status; + use crate::{ + models::sonarr_models::{ + Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, + }, + network::{ + network_tests::test_utils::mock_servarr_api, sonarr_network::SonarrEvent, Network, + NetworkEvent, NetworkResource, RequestMethod, + }, + }; + + const SERIES_JSON: &str = r#"{ + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "Blah blah blah", + "network": "HBO", + "seasons": [ + { + "seasonNumber": 1, + "monitored": true, + "statistics": { + "previousAiring": "2022-10-24T01:00:00Z", + "episodeFileCount": 10, + "episodeCount": 10, + "totalEpisodeCount": 10, + "sizeOnDisk": 36708563419, + "percentOfEpisodes": 100.0 + } + } + ], + "year": 2022, + "path": "/nfs/tv/Test", + "qualityProfileId": 6, + "languageProfileId": 1, + "seasonFolder": true, + "monitored": true, + "runtime": 63, + "tvdbId": 371572, + "seriesType": "standard", + "certification": "TV-MA", + "genres": ["cool", "family", "fun"], + "tags": [3], + "ratings": {"votes": 406744, "value": 8.4}, + "statistics": { + "seasonCount": 2, + "episodeFileCount": 18, + "episodeCount": 18, + "totalEpisodeCount": 50, + "sizeOnDisk": 63894022699, + "percentOfEpisodes": 100.0 + }, + "id": 1 + } +"#; + const EPISODE_JSON: &str = r#"{ + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "episodeFile": { + "relativePath": "/season 1/episode 1.mkv", + "path": "/nfs/tv/series/season 1/episode 1.mkv", + "size": 3543348019, + "dateAdded": "2024-02-10T07:28:45Z", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "mediaInfo": { + "audioBitrate": 0, + "audioChannels": 7.1, + "audioCodec": "AAC", + "audioLanguages": "eng", + "audioStreamCount": 1, + "videoBitDepth": 10, + "videoBitrate": 0, + "videoCodec": "x265", + "videoFps": 23.976, + "resolution": "1920x1080", + "runTime": "23:51", + "scanType": "Progressive", + "subtitles": "English" + } + }, + "hasFile": true, + "monitored": true, + "id": 1 + }"#; + + #[rstest] + fn test_resource_all_indexer_settings( + #[values( + SonarrEvent::GetAllIndexerSettings, + SonarrEvent::EditAllIndexerSettings(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/indexer"); + } + + #[rstest] + fn test_resource_episode( + #[values(SonarrEvent::GetEpisodes(None), SonarrEvent::GetEpisodeDetails(None))] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/episode"); + } + + #[rstest] + fn test_resource_series( + #[values( + SonarrEvent::AddSeries(None), + SonarrEvent::ListSeries, + SonarrEvent::GetSeriesDetails(None), + SonarrEvent::DeleteSeries(None), + SonarrEvent::EditSeries(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/series"); + } + + #[rstest] + fn test_resource_tag( + #[values( + SonarrEvent::AddTag(String::new()), + SonarrEvent::DeleteTag(0), + SonarrEvent::GetTags + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/tag"); + } + + #[rstest] + fn test_resource_host_config( + #[values(SonarrEvent::GetHostConfig, SonarrEvent::GetSecurityConfig)] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/config/host"); + } + + #[rstest] + fn test_resource_command( + #[values( + SonarrEvent::GetQueuedEvents, + SonarrEvent::StartTask(None), + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + SonarrEvent::TriggerAutomaticSeasonSearch(None), + SonarrEvent::TriggerAutomaticSeriesSearch(None), + SonarrEvent::UpdateAllSeries, + SonarrEvent::UpdateAndScanSeries(None), + SonarrEvent::UpdateDownloads + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/command"); + } + + #[rstest] + fn test_resource_indexer( + #[values( + SonarrEvent::GetIndexers, + SonarrEvent::DeleteIndexer(None), + SonarrEvent::EditIndexer(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/indexer"); + } + + #[rstest] + fn test_resource_history( + #[values(SonarrEvent::GetHistory(None), SonarrEvent::GetEpisodeHistory(None))] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/history"); + } + + #[rstest] + fn test_resource_queue( + #[values(SonarrEvent::GetDownloads, SonarrEvent::DeleteDownload(None))] event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/queue"); + } + + #[rstest] + fn test_resource_root_folder( + #[values( + SonarrEvent::GetRootFolders, + SonarrEvent::DeleteRootFolder(None), + SonarrEvent::AddRootFolder(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/rootfolder"); + } + + #[rstest] + fn test_resource_release( + #[values( + SonarrEvent::GetSeasonReleases(None), + SonarrEvent::GetEpisodeReleases(None) + )] + event: SonarrEvent, + ) { + assert_str_eq!(event.resource(), "/release"); + } + + #[rstest] + #[case(SonarrEvent::ClearBlocklist, "/blocklist/bulk")] + #[case(SonarrEvent::DeleteBlocklistItem(None), "/blocklist")] + #[case(SonarrEvent::DeleteEpisodeFile(None), "/episodefile")] + #[case(SonarrEvent::HealthCheck, "/health")] + #[case(SonarrEvent::GetBlocklist, "/blocklist?page=1&pageSize=10000")] + #[case(SonarrEvent::GetDiskSpace, "/diskspace")] + #[case(SonarrEvent::GetSeriesHistory(None), "/history/series")] + #[case(SonarrEvent::GetLanguageProfiles, "/languageprofile")] + #[case(SonarrEvent::GetLogs(Some(500)), "/log")] + #[case(SonarrEvent::GetQualityProfiles, "/qualityprofile")] + #[case(SonarrEvent::GetStatus, "/system/status")] + #[case(SonarrEvent::GetTasks, "/system/task")] + #[case(SonarrEvent::GetUpdates, "/update")] + #[case(SonarrEvent::MarkHistoryItemAsFailed(0), "/history/failed")] + #[case(SonarrEvent::SearchNewSeries(None), "/series/lookup")] + #[case(SonarrEvent::TestIndexer(None), "/indexer/test")] + #[case(SonarrEvent::TestAllIndexers, "/indexer/testall")] + fn test_resource(#[case] event: SonarrEvent, #[case] expected_uri: String) { + assert_str_eq!(event.resource(), expected_uri); + } + + #[test] + fn test_from_sonarr_event() { + assert_eq!( + NetworkEvent::Sonarr(SonarrEvent::HealthCheck), + NetworkEvent::from(SonarrEvent::HealthCheck) + ); + } + + #[tokio::test] + async fn test_handle_add_sonarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/nfs/test" + })), + Some(json!({})), + None, + SonarrEvent::AddRootFolder(None), + None, + None, + ) + .await; + + app_arc.lock().await.data.sonarr_data.edit_root_folder = Some("/nfs/test".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddRootFolder(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .edit_root_folder + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_root_folder_event_uses_provided_path() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "path": "/test/test" + })), + Some(json!({})), + None, + SonarrEvent::AddRootFolder(None), + None, + None, + ) + .await; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddRootFolder(Some("/test/test".to_owned()))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .edit_root_folder + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result()]); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_uses_provided_body() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 1234, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "standard", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + let body = AddSeriesBody { + tvdb_id: 1234, + title: "Test".to_owned(), + monitored: true, + root_folder_path: "/nfs2".to_owned(), + quality_profile_id: 2222, + language_profile_id: 2222, + series_type: "standard".to_owned(), + season_folder: true, + tags: vec![1, 2], + add_options: AddSeriesOptions { + monitor: "standard".to_owned(), + search_for_cutoff_unmet_episodes: true, + search_for_missing_episodes: true, + }, + }; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(Some(body))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + } + + #[tokio::test] + async fn test_handle_add_sonarr_series_event_reuse_existing_table_if_search_already_performed() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "tvdbId": 5678, + "title": "Test", + "monitored": true, + "rootFolderPath": "/nfs2", + "qualityProfileId": 2222, + "languageProfileId": 2222, + "seriesType": "standard", + "seasonFolder": true, + "tags": [1, 2], + "addOptions": { + "monitor": "all", + "searchForCutoffUnmetEpisodes": true, + "searchForMissingEpisodes": true + } + })), + Some(json!({})), + None, + SonarrEvent::AddSeries(None), + None, + None, + ) + .await; + + { + let mut app = app_arc.lock().await; + let mut add_series_modal = AddSeriesModal { + use_season_folder: true, + tags: "usenet, testing".into(), + ..AddSeriesModal::default() + }; + add_series_modal.root_folder_list.set_items(vec![ + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + }, + RootFolder { + id: 2, + path: "/nfs2".to_owned(), + accessible: true, + free_space: 21990232555520, + unmapped_folders: None, + }, + ]); + add_series_modal.root_folder_list.state.select(Some(1)); + add_series_modal + .quality_profile_list + .set_items(vec!["HD - 1080p".to_owned()]); + add_series_modal + .language_profile_list + .set_items(vec!["English".to_owned()]); + add_series_modal + .monitor_list + .set_items(Vec::from_iter(SeriesMonitor::iter())); + add_series_modal + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.add_series_modal = Some(add_series_modal); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = BiMap::from_iter([(2222, "English".to_owned())]); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let secondary_search_result = AddSeriesSearchResult { + tvdb_id: 5678, + ..add_series_search_result() + }; + let mut add_searched_series = StatefulTable::default(); + add_searched_series.set_items(vec![add_series_search_result(), secondary_search_result]); + add_searched_series.scroll_to_bottom(); + app.data.sonarr_data.add_searched_series = Some(add_searched_series); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::AddSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_series_modal + .is_none()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .current_selection() + .tvdb_id, + 5678 + ); + } + + #[tokio::test] + async fn test_handle_add_sonarr_tag() { + let tag_json = json!({ "id": 3, "label": "testing" }); + let response: Tag = serde_json::from_value(tag_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(tag_json), + None, + SonarrEvent::AddTag(String::new()), + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tag(tag) = network + .handle_sonarr_event(SonarrEvent::AddTag("testing".to_owned())) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + assert_eq!(tag, response); + } + } + + #[tokio::test] + async fn test_handle_clear_radarr_blocklist_event() { + let blocklist_items = vec![ + BlocklistItem { + id: 1, + ..blocklist_item() + }, + BlocklistItem { + id: 2, + ..blocklist_item() + }, + BlocklistItem { + id: 3, + ..blocklist_item() + }, + ]; + let expected_request_json = json!({ "ids": [1, 2, 3]}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + Some(expected_request_json), + None, + None, + SonarrEvent::ClearBlocklist, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(blocklist_items); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ClearBlocklist) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_blocklist_item_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteBlocklistItem(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .set_items(vec![blocklist_item()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteBlocklistItem(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_episode_file_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_episode_file_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_delete_sonarr_episode_file_event_empty_season_details_modal_panics() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteEpisodeFile(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::DeleteEpisodeFile(None)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_delete_sonarr_download_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .downloads + .set_items(vec![download_record()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteDownload(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_download_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteDownload(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteDownload(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_indexer_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteIndexer(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_indexer_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteIndexer(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteIndexer(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_root_folder_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .root_folders + .set_items(vec![root_folder()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteRootFolder(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_sonarr_root_folder_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteRootFolder(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteRootFolder(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_delete_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteSeries(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.series.set_items(vec![series()]); + app.data.sonarr_data.delete_series_files = true; + app.data.sonarr_data.add_list_exclusion = true; + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(!app_arc.lock().await.data.sonarr_data.delete_series_files); + assert!(!app_arc.lock().await.data.sonarr_data.add_list_exclusion); + } + + #[tokio::test] + async fn test_handle_delete_series_event_use_provided_params() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteSeries(None), + Some("/1"), + Some("deleteFiles=true&addImportExclusion=true"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let delete_series_params = DeleteSeriesParams { + id: 1, + delete_series_files: true, + add_list_exclusion: true, + }; + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteSeries(Some(delete_series_params))) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(!app_arc.lock().await.data.sonarr_data.delete_series_files); + assert!(!app_arc.lock().await.data.sonarr_data.add_list_exclusion); + } + + #[tokio::test] + async fn test_handle_delete_sonarr_tag_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Delete, + None, + None, + None, + SonarrEvent::DeleteTag(1), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DeleteTag(1)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_download_sonarr_release_event_uses_provided_params() { + let params = SonarrReleaseDownloadBody { + guid: "1234".to_owned(), + indexer_id: 2, + series_id: Some(1), + ..SonarrReleaseDownloadBody::default() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "guid": "1234", + "indexerId": 2, + "seriesId": 1, + })), + Some(json!({})), + None, + SonarrEvent::DownloadRelease(params.clone()), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::DownloadRelease(params)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_event() { + let indexer_settings_json = json!({ + "id": 1, + "minimumAge": 1, + "maximumSize": 12345, + "retention": 1, + "rssSyncInterval": 60 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + SonarrEvent::EditAllIndexerSettings(None), + None, + None, + ) + .await; + + app_arc.lock().await.data.sonarr_data.indexer_settings = Some(indexer_settings()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .indexer_settings + .is_none()); + } + + #[tokio::test] + async fn test_handle_edit_all_indexer_settings_event_uses_provided_settings() { + let indexer_settings_json = json!({ + "id": 1, + "minimumAge": 1, + "maximumSize": 12345, + "retention": 1, + "rssSyncInterval": 60 + }); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Put, + Some(indexer_settings_json), + None, + None, + SonarrEvent::EditAllIndexerSettings(None), + None, + None, + ) + .await; + + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditAllIndexerSettings( + Some(indexer_settings()) + )) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + app.data.sonarr_data.indexers.set_items(vec![indexer()]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_does_not_add_seed_ratio_when_seed_ratio_field_is_none_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let mut indexer = indexer(); + indexer.fields = Some( + indexer + .fields + .unwrap() + .into_iter() + .filter(|field| field.name != Some("seedCriteria.seedRatio".to_string())) + .collect(), + ); + app.data.sonarr_data.indexers.set_items(vec![indexer]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_populates_the_seed_ratio_value_when_seed_ratio_field_is_present_in_details( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let edit_indexer_modal = EditIndexerModal { + name: "Test Update".into(), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: "https://localhost:9696/1/".into(), + api_key: "test1234".into(), + seed_ratio: "1.3".into(), + tags: "usenet, testing".into(), + }; + app.data.sonarr_data.edit_indexer_modal = Some(edit_indexer_modal); + let mut indexer = indexer(); + indexer.fields = Some( + indexer + .fields + .unwrap() + .into_iter() + .map(|mut field| { + if field.name == Some("seedCriteria.seedRatio".to_string()) { + field.value = None; + field + } else { + field + } + }) + .collect(), + ); + app.data.sonarr_data.indexers.set_items(vec![indexer]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_indexer_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let expected_indexer_edit_body_json = json!({ + "enableRss": false, + "enableAutomaticSearch": false, + "enableInteractiveSearch": false, + "name": "Test Update", + "priority": 25, + "fields": [ + { + "name": "baseUrl", + "value": "https://localhost:9696/1/", + }, + { + "name": "apiKey", + "value": "test1234", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.3", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + name: Some("Test Update".to_owned()), + enable_rss: Some(false), + enable_automatic_search: Some(false), + enable_interactive_search: Some(false), + url: Some("https://localhost:9696/1/".to_owned()), + api_key: Some("test1234".to_owned()), + seed_ratio: Some("1.3".to_owned()), + tags: Some(vec![1, 2]), + priority: Some(25), + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_indexer_edit_body_json)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters_defaults_to_previous_values( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_sonarr_indexer_event_uses_provided_parameters_clears_tags_when_clear_tags_is_true( + ) { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1, 2], + "id": 1 + }); + let expected_edit_indexer_body = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "priority": 1, + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [], + "id": 1 + }); + let edit_indexer_params = EditIndexerParams { + indexer_id: 1, + clear_tags: true, + ..EditIndexerParams::default() + }; + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!( + "/api/v3{}/1?forceSave=true", + SonarrEvent::EditIndexer(None).resource() + ) + .as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_edit_indexer_body)) + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditIndexer(Some(edit_indexer_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "testing".to_owned())]); + let mut edit_series = EditSeriesModal { + tags: "usenet, testing".to_owned().into(), + path: "/nfs/Test Path".to_owned().into(), + monitored: Some(false), + use_season_folders: Some(false), + ..EditSeriesModal::default() + }; + edit_series + .quality_profile_list + .set_items(vec!["Any".to_owned(), "HD - 1080p".to_owned()]); + edit_series + .language_profile_list + .set_items(vec!["Any".to_owned(), "English".to_owned()]); + edit_series + .series_type_list + .set_items(Vec::from_iter(SeriesType::iter())); + app.data.sonarr_data.edit_series_modal = Some(edit_series); + app.data.sonarr_data.series.set_items(vec![Series { + monitored: false, + season_folder: false, + ..series() + }]); + app.data.sonarr_data.quality_profile_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "HD - 1080p".to_owned())]); + app.data.sonarr_data.language_profiles_map = + BiMap::from_iter([(1111, "Any".to_owned()), (2222, "English".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(None)) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + + let app = app_arc.lock().await; + assert!(app.data.sonarr_data.edit_series_modal.is_none()); + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters() { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("monitored").unwrap() = json!(false); + *expected_body.get_mut("seasonFolder").unwrap() = json!(false); + *expected_body.get_mut("seriesType").unwrap() = json!("standard"); + *expected_body.get_mut("qualityProfileId").unwrap() = json!(1111); + *expected_body.get_mut("languageProfileId").unwrap() = json!(1111); + *expected_body.get_mut("path").unwrap() = json!("/nfs/Test Path"); + *expected_body.get_mut("tags").unwrap() = json!([1, 2]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + monitored: Some(false), + use_season_folders: Some(false), + series_type: Some(SeriesType::Standard), + quality_profile_id: Some(1111), + language_profile_id: Some(1111), + root_folder_path: Some("/nfs/Test Path".to_owned()), + tags: Some(vec![1, 2]), + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_defaults_to_previous_values() { + let expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_edit_series_event_uses_provided_parameters_returns_empty_tags_vec_when_clear_tags_is_true( + ) { + let mut expected_body: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *expected_body.get_mut("tags").unwrap() = json!([]); + + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + let async_edit_server = server + .mock( + "PUT", + format!("/api/v3{}/1", SonarrEvent::EditSeries(None).resource()).as_str(), + ) + .with_status(202) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(expected_body)) + .create_async() + .await; + let edit_series_params = EditSeriesParams { + series_id: 1, + clear_tags: true, + ..EditSeriesParams::default() + }; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::EditSeries(Some(edit_series_params))) + .await + .is_ok()); + + async_details_server.assert_async().await; + async_edit_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event(#[values(true, false)] use_custom_sorting: bool) { + let blocklist_json = json!({"records": [{ + "seriesId": 1007, + "episodeIds": [42020], + "sourceTitle": "z series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123 + }, + { + "seriesId": 2001, + "episodeIds": [42018], + "sourceTitle": "A Series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456 + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let mut expected_blocklist = vec![ + BlocklistItem { + id: 123, + series_id: 1007, + source_title: "z series".into(), + episode_ids: vec![Number::from(42020)], + ..blocklist_item() + }, + BlocklistItem { + id: 456, + series_id: 2001, + source_title: "A Series".into(), + episode_ids: vec![Number::from(42018)], + ..blocklist_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + SonarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + expected_blocklist.sort_by(cmp_fn); + + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::BlocklistResponse(blocklist) = network + .handle_sonarr_event(SonarrEvent::GetBlocklist) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.blocklist.items, + expected_blocklist + ); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_blocklist_event_no_op_when_user_is_selecting_sort_options() { + let blocklist_json = json!({"records": [{ + "seriesId": 1007, + "episodeIds": [42020], + "sourceTitle": "z series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 123 + }, + { + "seriesId": 2001, + "episodeIds": [42018], + "sourceTitle": "A Series", + "language": { "id": 1, "name": "English" }, + "quality": { "quality": { "name": "Bluray-1080p" }}, + "date": "2024-02-10T07:28:45Z", + "protocol": "usenet", + "indexer": "NZBgeek (Prowlarr)", + "message": "test message", + "id": 456 + }]}); + let response: BlocklistResponse = serde_json::from_value(blocklist_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(blocklist_json), + None, + SonarrEvent::GetBlocklist, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.blocklist.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::BlocklistSortPrompt.into()); + let cmp_fn = |a: &BlocklistItem, b: &BlocklistItem| { + a.source_title + .to_lowercase() + .cmp(&b.source_title.to_lowercase()) + }; + let blocklist_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .blocklist + .sorting(vec![blocklist_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::BlocklistResponse(blocklist) = network + .handle_sonarr_event(SonarrEvent::GetBlocklist) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc.lock().await.data.sonarr_data.blocklist.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.blocklist.sort_asc); + assert_eq!(blocklist, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_downloads_event() { + let downloads_response_json = json!({ + "records": [{ + "title": "Test Download Title", + "status": "downloading", + "id": 1, + "episodeId": 1, + "size": 3543348019f64, + "sizeleft": 1771674009f64, + "outputPath": "/nfs/tv/Test show/season 1/", + "indexer": "kickass torrents", + "downloadClient": "transmission", + }] + }); + let response: DownloadsResponse = + serde_json::from_value(downloads_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(downloads_response_json), + None, + SonarrEvent::GetDownloads, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::DownloadsResponse(downloads) = network + .handle_sonarr_event(SonarrEvent::GetDownloads) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.downloads.items, + downloads_response().records + ); + assert_eq!(downloads, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_diskspace_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([ + { + "freeSpace": 1111, + "totalSpace": 2222, + }, + { + "freeSpace": 3333, + "totalSpace": 4444 + } + ])), + None, + SonarrEvent::GetDiskSpace, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let disk_space_vec = vec![ + DiskSpace { + free_space: 1111, + total_space: 2222, + }, + DiskSpace { + free_space: 3333, + total_space: 4444, + }, + ]; + + if let SonarrSerdeable::DiskSpaces(disk_space) = network + .handle_sonarr_event(SonarrEvent::GetDiskSpace) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.disk_space_vec, + disk_space_vec + ); + assert_eq!(disk_space, disk_space_vec); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_healthcheck_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + None, + None, + SonarrEvent::HealthCheck, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let _ = network.handle_sonarr_event(SonarrEvent::HealthCheck).await; + + async_server.assert_async().await; + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_episodes_event(#[values(true, false)] use_custom_sorting: bool) { + let episode_1 = Episode { + title: Some("z test".to_owned()), + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + title: Some("A test".to_owned()), + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_1.clone(), episode_2.clone()]; + let mut expected_sorted_episodes = vec![episode_1.clone(), episode_2.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode_1, episode_2])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_sorted_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + season_details_modal + .episodes + .sorting(vec![title_sort_option]); + } + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + expected_sorted_episodes + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_empty_season_details_modal() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([episode()])), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .items, + vec![episode()] + ); + assert_eq!(episodes, vec![episode()]); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_no_op_while_user_is_selecting_sort_options_on_table() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 1, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let episode_1 = Episode { + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + episode_file: None, + ..episode() + }; + let mut expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodesSortPrompt.into()); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.sort_asc = true; + let cmp_fn = |a: &Episode, b: &Episode| { + a.title + .as_ref() + .unwrap() + .to_lowercase() + .cmp(&b.title.as_ref().unwrap().to_lowercase()) + }; + expected_episodes.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + season_details_modal + .episodes + .sorting(vec![title_sort_option]); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episodes + .sort_asc + ); + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_host_config_event() { + let host_config_response = json!({ + "bindAddress": "*", + "port": 7878, + "urlBase": "some.test.site/sonarr", + "instanceName": "Sonarr", + "applicationUrl": "https://some.test.site:7878/sonarr", + "enableSsl": true, + "sslPort": 9898, + "sslCertPath": "/app/sonarr.pfx", + "sslCertPassword": "test" + }); + let response: HostConfig = serde_json::from_value(host_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(host_config_response), + None, + SonarrEvent::GetHostConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::HostConfig(host_config) = network + .handle_sonarr_event(SonarrEvent::GetHostConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(host_config, response); + } + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_history_event(#[values(true, false)] use_custom_sorting: bool) { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_uses_provided_items() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(Some(1000))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.history.items, + expected_history_items + ); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetHistory(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.history.sort_asc = true; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::HistorySortPrompt.into()); + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .history + .sorting(vec![history_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc.lock().await.data.sonarr_data.history.is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.history.sort_asc); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_indexers_event() { + let indexers_response_json = json!([{ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "supportsRss": true, + "supportsSearch": true, + "protocol": "torrent", + "priority": 25, + "downloadClientId": 0, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "implementationName": "Torznab", + "implementation": "Torznab", + "configContract": "TorznabSettings", + "tags": [1], + "id": 1 + }]); + let response: Vec = serde_json::from_value(indexers_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexers_response_json), + None, + SonarrEvent::GetIndexers, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Indexers(indexers) = network + .handle_sonarr_event(SonarrEvent::GetIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexers.items, + vec![indexer()] + ); + assert_eq!(indexers, response); + } + } + + #[tokio::test] + async fn test_handle_get_episodes_event_uses_provided_series_id() { + let episodes_json = json!([ + { + "id": 2, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 2, + "seasonNumber": 2, + "episodeNumber": 2, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + }, + { + "id": 1, + "seriesId": 2, + "tvdbId": 1234, + "episodeFileId": 1, + "seasonNumber": 1, + "episodeNumber": 1, + "title": "Something cool", + "airDateUtc": "2024-02-10T07:28:45Z", + "overview": "Okay so this one time at band camp...", + "hasFile": true, + "monitored": true + } + ]); + let episode_1 = Episode { + series_id: 2, + episode_file: None, + ..episode() + }; + let episode_2 = Episode { + id: 2, + episode_file_id: 2, + season_number: 2, + episode_number: 2, + series_id: 2, + episode_file: None, + ..episode() + }; + let expected_episodes = vec![episode_2.clone(), episode_1.clone()]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(episodes_json), + None, + SonarrEvent::GetEpisodes(None), + None, + Some("seriesId=2"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episodes(episodes) = network + .handle_sonarr_event(SonarrEvent::GetEpisodes(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episodes, expected_episodes); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .is_some()); + assert_eq!(episode, response); + + let app = app_arc.lock().await; + let episode_details_modal = app + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap(); + assert_str_eq!( + episode_details_modal.episode_details.get_text(), + formatdoc!( + "Title: Something cool + Season: 1 + Episode Number: 1 + Air Date: 2024-02-10 07:28:45 UTC + Status: Downloaded + Description: Okay so this one time at band camp..." + ) + ); + assert_str_eq!( + episode_details_modal.file_details, + formatdoc!( + "Relative Path: /season 1/episode 1.mkv + Absolute Path: /nfs/tv/series/season 1/episode 1.mkv + Size: 3.30 GB + Language: English + Date Added: 2024-02-10 07:28:45 UTC" + ) + ); + assert_str_eq!( + episode_details_modal.audio_details, + formatdoc!( + "Bitrate: 0 + Channels: 7.1 + Codec: AAC + Languages: eng + Stream Count: 1" + ) + ); + assert_str_eq!( + episode_details_modal.video_details, + formatdoc!( + "Bit Depth: 10 + Bitrate: 0 + Codec: x265 + FPS: 23.976 + Resolution: 1920x1080 + Scan Type: Progressive + Runtime: 23:51 + Subtitles: English" + ) + ); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_uses_provided_id() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(None), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_uses_provided_episode_id() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(Some(2)), + None, + Some("episodeId=2&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal + .as_mut() + .unwrap() + .episode_history + .sort_asc = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_episode_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(None), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episodes + .set_items(vec![episode()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_episode_history_event_empty_season_details_modal() { + let history_json = json!({"records": [{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]}); + let response: SonarrHistoryWrapper = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetEpisodeHistory(Some(1)), + None, + Some("episodeId=1&pageSize=1000&sortDirection=descending&sortKey=date"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryWrapper(history) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeHistory(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_history + .sort_asc + ); + assert_eq!(history, response); + } + } + + #[tokio::test] + async fn test_handle_get_episode_details_event_season_details_modal_not_required_in_cli_mode() { + let response: Episode = serde_json::from_str(EPISODE_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + app_arc.lock().await.cli_mode = true; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Episode(episode) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(episode, response); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_no_parameter_is_passed( + ) { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(None)) + .await + .unwrap(); + } + + #[tokio::test] + #[should_panic(expected = "Season details modal is empty")] + async fn test_handle_get_episode_details_event_requires_season_details_modal_to_be_some_when_in_tui_mode( + ) { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(EPISODE_JSON).unwrap()), + None, + SonarrEvent::GetEpisodeDetails(None), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeDetails(Some(1))) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_get_sonarr_language_profiles_event() { + let language_profiles_json = json!([{ + "id": 2222, + "name": "English" + }]); + let response: Vec = serde_json::from_value(language_profiles_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(language_profiles_json), + None, + SonarrEvent::GetLanguageProfiles, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LanguageProfiles(language_profiles) = network + .handle_sonarr_event(SonarrEvent::GetLanguageProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.language_profiles_map, + BiMap::from_iter([(2222i64, "English".to_owned())]) + ); + assert_eq!(language_profiles, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_logs_event() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 500, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "SonarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + SonarrEvent::GetLogs(None), + None, + Some("pageSize=500&sortDirection=descending&sortKey=time"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LogResponse(logs) = network + .handle_sonarr_event(SonarrEvent::GetLogs(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_logs_event_uses_provided_events() { + let expected_logs = vec![ + HorizontallyScrollableText::from( + "2023-05-20 21:29:16 UTC|FATAL|SonarrError|Some.Big.Bad.Exception|test exception", + ), + HorizontallyScrollableText::from("2023-05-20 21:29:16 UTC|INFO|TestLogger|test message"), + ]; + let logs_response_json = json!({ + "page": 1, + "pageSize": 1000, + "sortKey": "time", + "sortDirection": "descending", + "totalRecords": 2, + "records": [ + { + "time": "2023-05-20T21:29:16Z", + "level": "info", + "logger": "TestLogger", + "message": "test message", + "id": 1 + }, + { + "time": "2023-05-20T21:29:16Z", + "level": "fatal", + "logger": "SonarrError", + "exception": "test exception", + "exceptionType": "Some.Big.Bad.Exception", + "id": 2 + } + ] + }); + let response: LogResponse = serde_json::from_value(logs_response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(logs_response_json), + None, + SonarrEvent::GetLogs(Some(1000)), + None, + Some("pageSize=1000&sortDirection=descending&sortKey=time"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::LogResponse(logs) = network + .handle_sonarr_event(SonarrEvent::GetLogs(Some(1000))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.logs.items, + expected_logs + ); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .logs + .current_selection() + .text + .contains("INFO")); + assert_eq!(logs, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_quality_profiles_event() { + let quality_profile_json = json!([{ + "id": 2222, + "name": "HD - 1080p" + }]); + let response: Vec = + serde_json::from_value(quality_profile_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(quality_profile_json), + None, + SonarrEvent::GetQualityProfiles, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QualityProfiles(quality_profiles) = network + .handle_sonarr_event(SonarrEvent::GetQualityProfiles) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.quality_profile_map, + BiMap::from_iter([(2222i64, "HD - 1080p".to_owned())]) + ); + assert_eq!(quality_profiles, response); + } + } + + #[tokio::test] + async fn test_handle_get_queued_sonarr_events_event() { + let queued_events_json = json!([{ + "name": "RefreshMonitoredDownloads", + "commandName": "Refresh Monitored Downloads", + "status": "completed", + "queued": "2023-05-20T21:29:16Z", + "started": "2023-05-20T21:29:16Z", + "ended": "2023-05-20T21:29:16Z", + "duration": "00:00:00.5111547", + "trigger": "scheduled", + }]); + let response: Vec = serde_json::from_value(queued_events_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_event = QueueEvent { + name: "RefreshMonitoredDownloads".to_owned(), + command_name: "Refresh Monitored Downloads".to_owned(), + status: "completed".to_owned(), + queued: timestamp, + started: Some(timestamp), + ended: Some(timestamp), + duration: Some("00:00:00.5111547".to_owned()), + trigger: "scheduled".to_owned(), + }; + + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(queued_events_json), + None, + SonarrEvent::GetQueuedEvents, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::QueueEvents(events) = network + .handle_sonarr_event(SonarrEvent::GetQueuedEvents) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.queued_events.items, + vec![expected_event] + ); + assert_eq!(events, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_root_folders_event() { + let root_folder_json = json!([{ + "id": 1, + "path": "/nfs", + "accessible": true, + "freeSpace": 219902325555200u64, + }]); + let response: Vec = serde_json::from_value(root_folder_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(root_folder_json), + None, + SonarrEvent::GetRootFolders, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::RootFolders(root_folders) = network + .handle_sonarr_event(SonarrEvent::GetRootFolders) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.root_folders.items, + vec![root_folder()] + ); + assert_eq!(root_folders, response); + } + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event_empty_episode_details_modal() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_get_episode_releases_event_empty_season_details_modal_panics() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=1"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(None)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_get_episode_releases_event_uses_provided_series_id() { + let release_json = json!([{ + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }} + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetEpisodeReleases(None), + None, + Some("episodeId=2"), + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_mut() + .unwrap() + .episode_details_modal = Some(EpisodeDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetEpisodeReleases(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .episode_details_modal + .as_ref() + .unwrap() + .episode_releases + .items, + vec![release()] + ); + assert_eq!(releases_vec, vec![release()]); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_filtered_sonarr_release] + ); + assert_eq!(releases_vec, expected_raw_sonarr_releases); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_empty_season_details_modal() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "usenet", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_sonarr_release] + ); + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_uses_provided_series_id_and_season_number() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=2&seasonNumber=2"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(Some((2, 2)))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_filtered_sonarr_release] + ); + assert_eq!(releases_vec, expected_raw_sonarr_releases); + } + } + + #[tokio::test] + async fn test_handle_get_season_releases_event_filtered_series_and_filtered_seasons() { + let release_json = json!([ + { + "guid": "1234", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + "fullSeason": true + }, + { + "guid": "4567", + "protocol": "torrent", + "age": 1, + "title": "Test Release", + "indexer": "kickass torrents", + "indexerId": 2, + "size": 1234, + "rejected": true, + "rejections": [ "Unknown quality profile", "Release is already mapped" ], + "seeders": 2, + "leechers": 1, + "languages": [ { "id": 1, "name": "English" } ], + "quality": { "quality": { "name": "Bluray-1080p" }}, + } + ]); + let expected_filtered_sonarr_release = SonarrRelease { + full_season: true, + ..release() + }; + let expected_raw_sonarr_releases = vec![ + SonarrRelease { + full_season: true, + ..release() + }, + SonarrRelease { + guid: "4567".to_owned(), + ..release() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(release_json), + None, + SonarrEvent::GetSeasonReleases(None), + None, + Some("seriesId=1&seasonNumber=1"), + ) + .await; + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Releases(releases_vec) = network + .handle_sonarr_event(SonarrEvent::GetSeasonReleases(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .season_details_modal + .as_ref() + .unwrap() + .season_releases + .items, + vec![expected_filtered_sonarr_release] + ); + assert_eq!(releases_vec, expected_raw_sonarr_releases); + } + } + + #[rstest] + #[tokio::test] + async fn test_handle_list_series_event(#[values(true, false)] use_custom_sorting: bool) { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let expected_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let mut expected_sorted_series = vec![ + Series { + id: 1, + title: "z test".into(), + ..series() + }, + Series { + id: 2, + title: "A test".into(), + ..series() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + if use_custom_sorting { + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + expected_sorted_series.sort_by(cmp_fn); + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SeriesVec(series) = network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.series.items, + expected_sorted_series + ); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + assert_eq!(series, expected_series); + } + } + + #[tokio::test] + async fn test_handle_get_series_details_event() { + let expected_series: Series = serde_json::from_str(SERIES_JSON).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(serde_json::from_str(SERIES_JSON).unwrap()), + None, + SonarrEvent::GetSeriesDetails(None), + Some("/1"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Series(series) = network + .handle_sonarr_event(SonarrEvent::GetSeriesDetails(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(series, expected_series); + } + } + + #[tokio::test] + async fn test_handle_get_series_details_event_uses_provided_series_id() { + let expected_series: Series = Series { + id: 2, + ..serde_json::from_str(SERIES_JSON).unwrap() + }; + let mut response: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *response.get_mut("id").unwrap() = json!(2); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(response), + None, + SonarrEvent::GetSeriesDetails(Some(2)), + Some("/2"), + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Series(series) = network + .handle_sonarr_event(SonarrEvent::GetSeriesDetails(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(series, expected_series); + } + } + + #[rstest] + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event( + #[values(true, false)] use_custom_sorting: bool, + ) { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let mut expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + if use_custom_sorting { + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + expected_history_items.sort_by(cmp_fn); + + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + series_history_table.sorting(vec![history_sort_option]); + } + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_uses_provided_series_id() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(Some(2)), + None, + Some("seriesId=2"), + ) + .await; + app_arc.lock().await.data.sonarr_data.series_history = Some(StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(Some(2))) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_empty_series_history_table() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let expected_history_items = vec![ + SonarrHistoryItem { + id: 123, + episode_id: 1007, + source_title: "z episode".into(), + ..history_item() + }, + SonarrHistoryItem { + id: 456, + episode_id: 2001, + source_title: "A Episode".into(), + ..history_item() + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .items, + expected_history_items + ); + assert!( + !app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_series_history_event_no_op_when_user_is_selecting_sort_options() { + let history_json = json!([{ + "id": 123, + "sourceTitle": "z episode", + "episodeId": 1007, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }, + { + "id": 456, + "sourceTitle": "A Episode", + "episodeId": 2001, + "quality": { "quality": { "name": "Bluray-1080p" } }, + "language": { "id": 1, "name": "English" }, + "date": "2024-02-10T07:28:45Z", + "eventType": "grabbed", + "data": { + "droppedPath": "/nfs/nzbget/completed/series/Coolness/something.cool.mkv", + "importedPath": "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv" + } + }]); + let response: Vec = serde_json::from_value(history_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(history_json), + None, + SonarrEvent::GetSeriesHistory(None), + None, + Some("seriesId=1"), + ) + .await; + let cmp_fn = |a: &SonarrHistoryItem, b: &SonarrHistoryItem| { + a.source_title + .text + .to_lowercase() + .cmp(&b.source_title.text.to_lowercase()) + }; + let history_sort_option = SortOption { + name: "Source Title", + cmp_fn: Some(cmp_fn), + }; + let mut series_history_table = StatefulTable { + sort_asc: true, + ..StatefulTable::default() + }; + series_history_table.sorting(vec![history_sort_option]); + app_arc.lock().await.data.sonarr_data.series_history = Some(series_history_table); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesHistorySortPrompt.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SonarrHistoryItems(history_items) = network + .handle_sonarr_event(SonarrEvent::GetSeriesHistory(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .is_some()); + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .is_empty()); + assert!( + app_arc + .lock() + .await + .data + .sonarr_data + .series_history + .as_ref() + .unwrap() + .sort_asc + ); + assert_eq!(history_items, response); + } + } + + #[tokio::test] + async fn test_handle_get_series_event_no_op_while_user_is_selecting_sort_options() { + let mut series_1: Value = serde_json::from_str(SERIES_JSON).unwrap(); + let mut series_2: Value = serde_json::from_str(SERIES_JSON).unwrap(); + *series_1.get_mut("id").unwrap() = json!(1); + *series_1.get_mut("title").unwrap() = json!("z test"); + *series_2.get_mut("id").unwrap() = json!(2); + *series_2.get_mut("title").unwrap() = json!("A test"); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([series_1, series_2])), + None, + SonarrEvent::ListSeries, + None, + None, + ) + .await; + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::SeriesSortPrompt.into()); + app_arc.lock().await.data.sonarr_data.series.sort_asc = true; + let cmp_fn = |a: &Series, b: &Series| { + a.title + .text + .to_lowercase() + .cmp(&b.title.text.to_lowercase()) + }; + let title_sort_option = SortOption { + name: "Title", + cmp_fn: Some(cmp_fn), + }; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .sorting(vec![title_sort_option]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::ListSeries) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .series + .items + .is_empty()); + assert!(app_arc.lock().await.data.sonarr_data.series.sort_asc); + } + + #[tokio::test] + async fn test_handle_get_sonarr_security_config_event() { + let security_config_response = json!({ + "authenticationMethod": "forms", + "authenticationRequired": "disabledForLocalAddresses", + "username": "test", + "password": "some password", + "apiKey": "someApiKey12345", + "certificateValidation": "disabledForLocalAddresses", + }); + let response: SecurityConfig = + serde_json::from_value(security_config_response.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(security_config_response), + None, + SonarrEvent::GetSecurityConfig, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::SecurityConfig(security_config) = network + .handle_sonarr_event(SonarrEvent::GetSecurityConfig) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(security_config, response); + } + } + + #[tokio::test] + async fn test_handle_get_status_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!({ + "version": "v1", + "startTime": "2023-02-25T20:16:43Z" + })), + None, + SonarrEvent::GetStatus, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let date_time = DateTime::from(DateTime::parse_from_rfc3339("2023-02-25T20:16:43Z").unwrap()) + as DateTime; + + if let SonarrSerdeable::SystemStatus(status) = network + .handle_sonarr_event(SonarrEvent::GetStatus) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!(app_arc.lock().await.data.sonarr_data.version, "v1"); + assert_eq!(app_arc.lock().await.data.sonarr_data.start_time, date_time); + assert_eq!( + status, + SystemStatus { + version: "v1".to_owned(), + start_time: date_time + } + ); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_tags_event() { + let tags_json = json!([{ + "id": 2222, + "label": "usenet" + }]); + let response: Vec = serde_json::from_value(tags_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tags_json), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tags(tags) = network + .handle_sonarr_event(SonarrEvent::GetTags) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([(2222i64, "usenet".to_owned())]) + ); + assert_eq!(tags, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_tasks_event() { + let tasks_json = json!([{ + "name": "Application Update Check", + "taskName": "ApplicationUpdateCheck", + "interval": 360, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + }, + { + "name": "Backup", + "taskName": "Backup", + "interval": 10080, + "lastExecution": "2023-05-20T21:29:16Z", + "nextExecution": "2023-05-20T21:29:16Z", + }]); + let response: Vec = serde_json::from_value(tasks_json.clone()).unwrap(); + let timestamp = DateTime::from(DateTime::parse_from_rfc3339("2023-05-20T21:29:16Z").unwrap()); + let expected_tasks = vec![ + SonarrTask { + name: "Application Update Check".to_owned(), + task_name: SonarrTaskName::ApplicationUpdateCheck, + interval: 360, + last_execution: timestamp, + next_execution: timestamp, + }, + SonarrTask { + name: "Backup".to_owned(), + task_name: SonarrTaskName::Backup, + interval: 10080, + last_execution: timestamp, + next_execution: timestamp, + }, + ]; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(tasks_json), + None, + SonarrEvent::GetTasks, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Tasks(tasks) = network + .handle_sonarr_event(SonarrEvent::GetTasks) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.tasks.items, + expected_tasks + ); + assert_eq!(tasks, response); + } + } + + #[tokio::test] + async fn test_handle_get_sonarr_updates_event() { + let updates_json = json!([{ + "version": "4.3.2.1", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": true, + "installedOn": "2023-04-15T02:02:53Z", + "latest": true, + "changes": { + "new": [ + "Cool new thing" + ], + "fixed": [ + "Some bugs killed" + ] + }, + }, + { + "version": "3.2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "installedOn": "2023-04-15T02:02:53Z", + "latest": false, + "changes": { + "new": [ + "Cool new thing (old)", + "Other cool new thing (old)" + ], + }, + }, + { + "version": "2.1.0", + "releaseDate": "2023-04-15T02:02:53Z", + "installed": false, + "latest": false, + "changes": { + "fixed": [ + "Killed bug 1", + "Fixed bug 2" + ] + }, + }]); + let response: Vec = serde_json::from_value(updates_json.clone()).unwrap(); + let line_break = "-".repeat(200); + let expected_text = ScrollableText::with_string(formatdoc!( + " + The latest version of Sonarr is already installed + + 4.3.2.1 - 2023-04-15 02:02:53 UTC (Currently Installed) + {line_break} + New: + * Cool new thing + Fixed: + * Some bugs killed + + + 3.2.1.0 - 2023-04-15 02:02:53 UTC (Previously Installed) + {line_break} + New: + * Cool new thing (old) + * Other cool new thing (old) + + + 2.1.0 - 2023-04-15 02:02:53 UTC + {line_break} + Fixed: + * Killed bug 1 + * Fixed bug 2" + )); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(updates_json), + None, + SonarrEvent::GetUpdates, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Updates(updates) = network + .handle_sonarr_event(SonarrEvent::GetUpdates) + .await + .unwrap() + { + async_server.assert_async().await; + assert_str_eq!( + app_arc.lock().await.data.sonarr_data.updates.get_text(), + expected_text.get_text() + ); + assert_eq!(updates, response); + } + } + + #[tokio::test] + async fn test_handle_mark_sonarr_history_item_as_failed_event() { + let expected_history_item_id = 1; + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(json!({})), + None, + SonarrEvent::MarkHistoryItemAsFailed(expected_history_item_id), + Some("/1"), + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::MarkHistoryItemAsFailed( + expected_history_item_id + )) + .await + .is_ok()); + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_search_new_series_event() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .as_ref() + .unwrap() + .items, + vec![add_series_search_result()] + ); + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_uses_provided_query() { + let add_series_search_result_json = json!([{ + "tvdbId": 1234, + "title": "Test", + "status": "continuing", + "ended": false, + "overview": "New series blah blah blah", + "genres": ["cool", "family", "fun"], + "year": 2023, + "network": "Prime Video", + "runtime": 60, + "ratings": { "votes": 406744, "value": 8.4 }, + "statistics": { "seasonCount": 3 } + }]); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(add_series_search_result_json), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::AddSeriesSearchResults(add_series_search_results) = network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(Some("test term".into()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(add_series_search_results, vec![add_series_search_result()]); + } + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_results() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(json!([])), + None, + SonarrEvent::SearchNewSeries(None), + None, + Some("term=test%20term"), + ) + .await; + app_arc.lock().await.data.sonarr_data.add_series_search = Some("test term".into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::AddSeriesEmptySearchResults.into() + ); + } + + #[tokio::test] + async fn test_handle_search_new_series_event_no_panic_on_race_condition() { + let resource = format!( + "{}?term=test%20term", + SonarrEvent::SearchNewSeries(None).resource() + ); + let mut server = Server::new_async().await; + let mut async_server = server + .mock( + &RequestMethod::Get.to_string().to_uppercase(), + format!("/api/v3{resource}").as_str(), + ) + .match_header("X-Api-Key", "test1234"); + async_server = async_server.expect_at_most(0).create_async().await; + + let host = Some(server.host_with_port().split(':').collect::>()[0].to_owned()); + let port = Some( + server.host_with_port().split(':').collect::>()[1] + .parse() + .unwrap(), + ); + let mut app = App::default(); + let sonarr_config = ServarrConfig { + host, + port, + api_token: "test1234".to_owned(), + ..ServarrConfig::default() + }; + app.config.sonarr = Some(sonarr_config); + let app_arc = Arc::new(Mutex::new(app)); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::Series.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::SearchNewSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .add_searched_series + .is_none()); + assert_eq!( + app_arc.lock().await.get_current_route(), + &ActiveSonarrBlock::Series.into() + ); + } + + #[tokio::test] + async fn test_handle_start_sonarr_task_event() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationUpdateCheck" + })), + Some(response.clone()), + None, + SonarrEvent::StartTask(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .tasks + .set_items(vec![SonarrTask { + task_name: SonarrTaskName::default(), + ..SonarrTask::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::StartTask(None)) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + + #[tokio::test] + async fn test_handle_start_sonarr_task_event_uses_provided_task_name() { + let response = json!({ "test": "test"}); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "ApplicationUpdateCheck" + })), + Some(response.clone()), + None, + SonarrEvent::StartTask(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::StartTask(Some(SonarrTaskName::default()))) + .await + .unwrap() + { + async_server.assert_async().await; + assert_eq!(value, response); + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_error() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let response_json = json!([ + { + "isWarning": false, + "propertyName": "", + "errorMessage": "test failure", + "severity": "error" + }]); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(400) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body(response_json.to_string()) + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(None)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_error, + Some("\"test failure\"".to_owned()) + ); + assert_eq!(value, response_json) + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_success() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(vec![indexer()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(None)) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!( + app_arc.lock().await.data.sonarr_data.indexer_test_error, + None + ); + assert_eq!(value, json!({})); + } + } + + #[tokio::test] + async fn test_handle_test_sonarr_indexer_event_success_uses_provided_id() { + let indexer_details_json = json!({ + "enableRss": true, + "enableAutomaticSearch": true, + "enableInteractiveSearch": true, + "name": "Test Indexer", + "fields": [ + { + "name": "baseUrl", + "value": "https://test.com", + }, + { + "name": "apiKey", + "value": "", + }, + { + "name": "seedCriteria.seedRatio", + "value": "1.2", + }, + ], + "tags": [1], + "id": 1 + }); + let (async_details_server, app_arc, mut server) = mock_servarr_api( + RequestMethod::Get, + None, + Some(indexer_details_json.clone()), + None, + SonarrEvent::GetIndexers, + Some("/1"), + None, + ) + .await; + let async_test_server = server + .mock( + "POST", + format!("/api/v3{}", SonarrEvent::TestIndexer(None).resource()).as_str(), + ) + .with_status(200) + .match_header("X-Api-Key", "test1234") + .match_body(Matcher::Json(indexer_details_json.clone())) + .with_body("{}") + .create_async() + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::Value(value) = network + .handle_sonarr_event(SonarrEvent::TestIndexer(Some(1))) + .await + .unwrap() + { + async_details_server.assert_async().await; + async_test_server.assert_async().await; + assert_eq!(value, json!({})); + } + } + + #[tokio::test] + async fn test_handle_test_all_sonarr_indexers_event() { + let indexers = vec![ + Indexer { + id: 1, + name: Some("Test 1".to_owned()), + ..Indexer::default() + }, + Indexer { + id: 2, + name: Some("Test 2".to_owned()), + ..Indexer::default() + }, + ]; + let indexer_test_results_modal_items = vec![ + IndexerTestResultModalItem { + name: "Test 1".to_owned(), + is_valid: true, + validation_failures: HorizontallyScrollableText::default(), + }, + IndexerTestResultModalItem { + name: "Test 2".to_owned(), + is_valid: false, + validation_failures: "Failure for field 'test field 1': test error message, Failure for field 'test field 2': test error message 2".into(), + }, + ]; + let response_json = json!([ + { + "id": 1, + "isValid": true, + "validationFailures": [] + }, + { + "id": 2, + "isValid": false, + "validationFailures": [ + { + "propertyName": "test field 1", + "errorMessage": "test error message", + "severity": "error" + }, + { + "propertyName": "test field 2", + "errorMessage": "test error message 2", + "severity": "error" + }, + ] + }]); + let response: Vec = serde_json::from_value(response_json.clone()).unwrap(); + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + None, + Some(response_json), + Some(400), + SonarrEvent::TestAllIndexers, + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .indexers + .set_items(indexers); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + if let SonarrSerdeable::IndexerTestResults(results) = network + .handle_sonarr_event(SonarrEvent::TestAllIndexers) + .await + .unwrap() + { + async_server.assert_async().await; + assert!(app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .is_some()); + assert_eq!( + app_arc + .lock() + .await + .data + .sonarr_data + .indexer_test_all_results + .as_ref() + .unwrap() + .items, + indexer_test_results_modal_items + ); + assert_eq!(results, response); + } + } + + #[tokio::test] + async fn test_handle_trigger_automatic_episode_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + None, + None, + ) + .await; + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![episode()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_episode_search_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(Some(1)), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + #[should_panic(expected = "Season details have not been loaded")] + async fn test_handle_trigger_automatic_episode_search_event_empty_season_details_modal_panics() { + let (_async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "EpisodeSearch", + "episodeIds": [ 1 ] + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticEpisodeSearch(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticEpisodeSearch(None)) + .await + .unwrap(); + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 1, + "seasonNumber": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event_uses_provided_series_id_and_season_number( + ) { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 2, + "seasonNumber": 2 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![season()]); + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(Some((2, 2)))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_season_search_event_filtered_series_and_filtered_seasons() + { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeasonSearch", + "seriesId": 1, + "seasonNumber": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeasonSearch(None), + None, + None, + ) + .await; + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + app_arc.lock().await.data.sonarr_data.season_details_modal = + Some(SeasonDetailsModal::default()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeasonSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_series_search_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeriesSearch", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeriesSearch(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_trigger_automatic_series_search_event_uses_provided_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "SeriesSearch", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::TriggerAutomaticSeriesSearch(None), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::TriggerAutomaticSeriesSearch(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_all_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + })), + Some(json!({})), + None, + SonarrEvent::UpdateAllSeries, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAllSeries) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_and_scan_series_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + "seriesId": 1, + })), + Some(json!({})), + None, + SonarrEvent::UpdateAndScanSeries(None), + None, + None, + ) + .await; + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![series()]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(None)) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_and_scan_series_event_uses_provied_series_id() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshSeries", + "seriesId": 1 + })), + Some(json!({})), + None, + SonarrEvent::UpdateAndScanSeries(Some(1)), + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateAndScanSeries(Some(1))) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_handle_update_sonarr_downloads_event() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ + "name": "RefreshMonitoredDownloads" + })), + Some(json!({})), + None, + SonarrEvent::UpdateDownloads, + None, + None, + ) + .await; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert!(network + .handle_sonarr_event(SonarrEvent::UpdateDownloads) + .await + .is_ok()); + + async_server.assert_async().await; + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec() { + let app_arc = Arc::new(Mutex::new(App::default())); + let tags = " test,hi ,, usenet ".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.tags_map = BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "hi".to_owned()), + ]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + assert_eq!( + network.extract_and_add_sonarr_tag_ids_vec(tags).await, + vec![2, 3, 1] + ); + } + + #[tokio::test] + async fn test_extract_and_add_sonarr_tag_ids_vec_add_missing_tags_first() { + let (async_server, app_arc, _server) = mock_servarr_api( + RequestMethod::Post, + Some(json!({ "label": "testing" })), + Some(json!({ "id": 3, "label": "testing" })), + None, + SonarrEvent::GetTags, + None, + None, + ) + .await; + let tags = "usenet, test, testing".to_owned(); + { + let mut app = app_arc.lock().await; + app.data.sonarr_data.add_series_modal = Some(AddSeriesModal { + tags: tags.clone().into(), + ..AddSeriesModal::default() + }); + app.data.sonarr_data.tags_map = + BiMap::from_iter([(1, "usenet".to_owned()), (2, "test".to_owned())]); + } + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let tag_ids_vec = network.extract_and_add_sonarr_tag_ids_vec(tags).await; + + async_server.assert_async().await; + assert_eq!(tag_ids_vec, vec![1, 2, 3]); + assert_eq!( + app_arc.lock().await.data.sonarr_data.tags_map, + BiMap::from_iter([ + (1, "usenet".to_owned()), + (2, "test".to_owned()), + (3, "testing".to_owned()) + ]) + ); + } + + #[tokio::test] + async fn test_extract_series_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + + #[tokio::test] + async fn test_extract_series_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .series + .set_items(vec![Series { + id: 1, + ..Series::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(series_id_param, "seriesId=2"); + } + + #[tokio::test] + async fn test_extract_series_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_series = StatefulTable::default(); + filtered_series.set_filtered_items(vec![Series { + id: 1, + ..Series::default() + }]); + app_arc.lock().await.data.sonarr_data.series = filtered_series; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, series_id_param) = network.extract_series_id(None).await; + + assert_eq!(id, 1); + assert_str_eq!(series_id_param, "seriesId=1"); + } + + #[tokio::test] + async fn test_extract_season_number() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_season_number_uses_provided_season_number() { + let app_arc = Arc::new(Mutex::new(App::default())); + app_arc + .lock() + .await + .data + .sonarr_data + .seasons + .set_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + let (id, season_number_param) = network.extract_season_number(Some(2)).await; + + assert_eq!(id, 2); + assert_str_eq!(season_number_param, "seasonNumber=2"); + } + + #[tokio::test] + async fn test_extract_season_number_filtered_seasons() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_seasons = StatefulTable::default(); + filtered_seasons.set_filtered_items(vec![Season { + season_number: 1, + ..Season::default() + }]); + app_arc.lock().await.data.sonarr_data.seasons = filtered_seasons; + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let (id, season_number_param) = network.extract_season_number(None).await; + + assert_eq!(id, 1); + assert_str_eq!(season_number_param, "seasonNumber=1"); + } + + #[tokio::test] + async fn test_extract_episode_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(None).await; + + assert_eq!(id, 1); + } + + #[tokio::test] + async fn test_extract_episode_id_uses_provided_id() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut season_details_modal = SeasonDetailsModal::default(); + season_details_modal.episodes.set_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(Some(2)).await; + + assert_eq!(id, 2); + } + + #[tokio::test] + async fn test_extract_episode_id_filtered_series() { + let app_arc = Arc::new(Mutex::new(App::default())); + let mut filtered_episodes = StatefulTable::default(); + filtered_episodes.set_filtered_items(vec![Episode { + id: 1, + ..Episode::default() + }]); + let season_details_modal = SeasonDetailsModal { + episodes: filtered_episodes, + ..SeasonDetailsModal::default() + }; + app_arc.lock().await.data.sonarr_data.season_details_modal = Some(season_details_modal); + app_arc + .lock() + .await + .push_navigation_stack(ActiveSonarrBlock::EpisodeDetails.into()); + let mut network = Network::new(&app_arc, CancellationToken::new(), Client::new()); + + let id = network.extract_episode_id(None).await; + + assert_eq!(id, 1); + } + + #[test] + fn test_get_episode_status_downloaded() { + assert_str_eq!(get_episode_status(true, &[], 0), "Downloaded"); + } + + #[test] + fn test_get_episode_status_missing() { + let download_record = DownloadRecord { + episode_id: 1, + ..DownloadRecord::default() + }; + + assert_str_eq!( + get_episode_status(false, &[download_record.clone()], 0), + "Missing" + ); + + assert_str_eq!(get_episode_status(false, &[download_record], 1), "Missing"); + } + + #[test] + fn test_get_episode_status_downloading() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: 1, + status: "downloading".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Downloading" + ); + } + + #[test] + fn test_get_episode_status_awaiting_import() { + assert_str_eq!( + get_episode_status( + false, + &[DownloadRecord { + episode_id: 1, + status: "completed".to_owned(), + ..DownloadRecord::default() + }], + 1 + ), + "Awaiting Import" + ); + } + + fn add_series_search_result() -> AddSeriesSearchResult { + AddSeriesSearchResult { + tvdb_id: 1234, + title: HorizontallyScrollableText::from("Test"), + status: Some("continuing".to_owned()), + ended: false, + overview: Some("New series blah blah blah".to_owned()), + genres: genres(), + year: 2023, + network: Some("Prime Video".to_owned()), + runtime: 60, + ratings: Some(rating()), + statistics: Some(add_series_search_result_statistics()), + } + } + + fn add_series_search_result_statistics() -> AddSeriesSearchResultStatistics { + AddSeriesSearchResultStatistics { season_count: 3 } + } + + fn blocklist_item() -> BlocklistItem { + BlocklistItem { + id: 1, + series_id: 1, + episode_ids: vec![Number::from(1)], + source_title: "Test Source Title".to_owned(), + language: language(), + quality: quality_wrapper(), + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + protocol: "usenet".to_owned(), + indexer: "NZBgeek (Prowlarr)".to_owned(), + message: "test message".to_owned(), + } + } + + fn download_record() -> DownloadRecord { + DownloadRecord { + title: "Test Download Title".to_owned(), + status: "downloading".to_owned(), + id: 1, + episode_id: 1, + size: 3543348019f64, + sizeleft: 1771674009f64, + output_path: Some(HorizontallyScrollableText::from( + "/nfs/tv/Test show/season 1/", + )), + indexer: "kickass torrents".to_owned(), + download_client: "transmission".to_owned(), + } + } + + fn downloads_response() -> DownloadsResponse { + DownloadsResponse { + records: vec![download_record()], + } + } + + fn episode() -> Episode { + Episode { + id: 1, + series_id: 1, + tvdb_id: 1234, + episode_file_id: 1, + season_number: 1, + episode_number: 1, + title: Some("Something cool".to_owned()), + air_date_utc: Some(DateTime::from( + DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap(), + )), + overview: Some("Okay so this one time at band camp...".to_owned()), + has_file: true, + monitored: true, + episode_file: Some(episode_file()), + } + } + + fn episode_file() -> EpisodeFile { + EpisodeFile { + relative_path: "/season 1/episode 1.mkv".to_owned(), + path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(), + size: 3543348019, + language: language(), + date_added: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + media_info: Some(media_info()), + } + } + + fn genres() -> Vec { + vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()] + } + + fn history_data() -> SonarrHistoryData { + SonarrHistoryData { + dropped_path: Some("/nfs/nzbget/completed/series/Coolness/something.cool.mkv".to_owned()), + imported_path: Some( + "/nfs/tv/Coolness/Season 1/Coolness - S01E01 - Something Cool Bluray-1080p.mkv".to_owned(), + ), + ..SonarrHistoryData::default() + } + } + + fn history_item() -> SonarrHistoryItem { + SonarrHistoryItem { + id: 1, + source_title: "Test source".into(), + episode_id: 1, + quality: quality_wrapper(), + language: language(), + date: DateTime::from(DateTime::parse_from_rfc3339("2024-02-10T07:28:45Z").unwrap()), + event_type: "grabbed".into(), + data: history_data(), + } + } + + fn indexer() -> Indexer { + Indexer { + enable_rss: true, + enable_automatic_search: true, + enable_interactive_search: true, + supports_rss: true, + supports_search: true, + protocol: "torrent".to_owned(), + priority: 25, + download_client_id: 0, + name: Some("Test Indexer".to_owned()), + implementation_name: Some("Torznab".to_owned()), + implementation: Some("Torznab".to_owned()), + config_contract: Some("TorznabSettings".to_owned()), + tags: vec![Number::from(1)], + id: 1, + fields: Some(vec![ + IndexerField { + name: Some("baseUrl".to_owned()), + value: Some(json!("https://test.com")), + }, + IndexerField { + name: Some("apiKey".to_owned()), + value: Some(json!("")), + }, + IndexerField { + name: Some("seedCriteria.seedRatio".to_owned()), + value: Some(json!("1.2")), + }, + ]), + } + } + + fn indexer_settings() -> IndexerSettings { + IndexerSettings { + id: 1, + minimum_age: 1, + retention: 1, + maximum_size: 12345, + rss_sync_interval: 60, + } + } + + fn language() -> Language { + Language { + id: 1, + name: "English".to_owned(), + } + } + + fn media_info() -> MediaInfo { + MediaInfo { + audio_bitrate: 0, + audio_channels: Number::from_f64(7.1).unwrap(), + audio_codec: Some("AAC".to_owned()), + audio_languages: Some("eng".to_owned()), + audio_stream_count: 1, + video_bit_depth: 10, + video_bitrate: 0, + video_codec: "x265".to_owned(), + video_fps: Number::from_f64(23.976).unwrap(), + resolution: "1920x1080".to_owned(), + run_time: "23:51".to_owned(), + scan_type: "Progressive".to_owned(), + subtitles: Some("English".to_owned()), + } + } + fn quality() -> Quality { + Quality { + name: "Bluray-1080p".to_owned(), + } + } + + fn quality_wrapper() -> QualityWrapper { + QualityWrapper { quality: quality() } + } + + fn rating() -> Rating { + Rating { + votes: 406744, + value: 8.4, + } + } + + fn season() -> Season { + Season { + season_number: 1, + monitored: true, + statistics: season_statistics(), + } + } + + fn season_statistics() -> SeasonStatistics { + SeasonStatistics { + previous_airing: Some(DateTime::from( + DateTime::parse_from_rfc3339("2022-10-24T01:00:00Z").unwrap(), + )), + next_airing: None, + episode_file_count: 10, + episode_count: 10, + total_episode_count: 10, + size_on_disk: 36708563419, + percent_of_episodes: 100.0, + } + } + + fn series() -> Series { + Series { + title: "Test".to_owned().into(), + status: SeriesStatus::Continuing, + ended: false, + overview: Some("Blah blah blah".to_owned()), + network: Some("HBO".to_owned()), + seasons: Some(vec![season()]), + year: 2022, + path: "/nfs/tv/Test".to_owned(), + quality_profile_id: 6, + language_profile_id: 1, + season_folder: true, + monitored: true, + runtime: 63, + tvdb_id: 371572, + series_type: SeriesType::Standard, + certification: Some("TV-MA".to_owned()), + genres: vec!["cool".to_owned(), "family".to_owned(), "fun".to_owned()], + tags: vec![Number::from(3)], + ratings: rating(), + statistics: Some(series_statistics()), + id: 1, + } + } + + fn series_statistics() -> SeriesStatistics { + SeriesStatistics { + season_count: 2, + episode_file_count: 18, + episode_count: 18, + total_episode_count: 50, + size_on_disk: 63894022699, + percent_of_episodes: 100.0, + } + } + + fn rejections() -> Vec { + vec![ + "Unknown quality profile".to_owned(), + "Release is already mapped".to_owned(), + ] + } + + fn release() -> SonarrRelease { + SonarrRelease { + guid: "1234".to_owned(), + protocol: "torrent".to_owned(), + age: 1, + title: HorizontallyScrollableText::from("Test Release"), + indexer: "kickass torrents".to_owned(), + indexer_id: 2, + size: 1234, + rejected: true, + rejections: Some(rejections()), + seeders: Some(Number::from(2)), + leechers: Some(Number::from(1)), + languages: Some(vec![language()]), + quality: quality_wrapper(), + full_season: false, + } + } + + fn root_folder() -> RootFolder { + RootFolder { + id: 1, + path: "/nfs".to_owned(), + accessible: true, + free_space: 219902325555200, + unmapped_folders: None, + } + } +} diff --git a/src/ui/radarr_ui/collections/collection_details_ui.rs b/src/ui/radarr_ui/collections/collection_details_ui.rs index 25f19e4..917018c 100644 --- a/src/ui/radarr_ui/collections/collection_details_ui.rs +++ b/src/ui/radarr_ui/collections/collection_details_ui.rs @@ -11,7 +11,7 @@ use crate::models::radarr_models::CollectionMovie; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::ui::radarr_ui::collections::draw_collections; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::{ diff --git a/src/ui/radarr_ui/collections/edit_collection_ui.rs b/src/ui/radarr_ui/collections/edit_collection_ui.rs index c005f99..eac0943 100644 --- a/src/ui/radarr_ui/collections/edit_collection_ui.rs +++ b/src/ui/radarr_ui/collections/edit_collection_ui.rs @@ -12,7 +12,7 @@ use crate::models::servarr_data::radarr::modals::EditCollectionModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, COLLECTION_DETAILS_BLOCKS, EDIT_COLLECTION_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::radarr_ui::collections::collection_details_ui::CollectionDetailsUi; use crate::ui::radarr_ui::collections::draw_collections; diff --git a/src/ui/radarr_ui/indexers/mod.rs b/src/ui/radarr_ui/indexers/mod.rs index 93a22a8..d79c84f 100644 --- a/src/ui/radarr_ui/indexers/mod.rs +++ b/src/ui/radarr_ui/indexers/mod.rs @@ -5,8 +5,8 @@ use ratatui::widgets::{Cell, Row}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::Indexer; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, INDEXERS_BLOCKS}; +use crate::models::servarr_models::Indexer; use crate::models::Route; use crate::ui::radarr_ui::indexers::edit_indexer_ui::EditIndexerUi; use crate::ui::radarr_ui::indexers::indexer_settings_ui::IndexerSettingsUi; diff --git a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs index e1a1b59..1b36a45 100644 --- a/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs +++ b/src/ui/radarr_ui/indexers/test_all_indexers_ui.rs @@ -1,6 +1,6 @@ use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; use crate::app::App; -use crate::models::servarr_data::radarr::modals::IndexerTestResultModalItem; +use crate::models::servarr_data::modals::IndexerTestResultModalItem; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; use crate::models::Route; use crate::ui::radarr_ui::indexers::draw_indexers; diff --git a/src/ui/radarr_ui/library/add_movie_ui.rs b/src/ui/radarr_ui/library/add_movie_ui.rs index 0008ac1..6903a6a 100644 --- a/src/ui/radarr_ui/library/add_movie_ui.rs +++ b/src/ui/radarr_ui/library/add_movie_ui.rs @@ -12,7 +12,7 @@ use crate::app::radarr::radarr_context_clues::{ use crate::models::radarr_models::AddMovieSearchResult; use crate::models::servarr_data::radarr::modals::AddMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ADD_MOVIE_BLOCKS}; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::ui::radarr_ui::collections::{draw_collection_details, draw_collections}; use crate::ui::radarr_ui::library::draw_library; use crate::ui::styles::ManagarrStyle; diff --git a/src/ui/radarr_ui/library/edit_movie_ui.rs b/src/ui/radarr_ui/library/edit_movie_ui.rs index 7930315..520bf2d 100644 --- a/src/ui/radarr_ui/library/edit_movie_ui.rs +++ b/src/ui/radarr_ui/library/edit_movie_ui.rs @@ -13,7 +13,7 @@ use crate::models::servarr_data::radarr::modals::EditMovieModal; use crate::models::servarr_data::radarr::radarr_data::{ ActiveRadarrBlock, EDIT_MOVIE_BLOCKS, MOVIE_DETAILS_BLOCKS, }; -use crate::models::Route; +use crate::models::{EnumDisplayStyle, Route}; use crate::render_selectable_input_box; use crate::ui::radarr_ui::library::draw_library; use crate::ui::radarr_ui::library::movie_details_ui::MovieDetailsUi; diff --git a/src/ui/radarr_ui/library/movie_details_ui.rs b/src/ui/radarr_ui/library/movie_details_ui.rs index b8218bf..ff7dd77 100644 --- a/src/ui/radarr_ui/library/movie_details_ui.rs +++ b/src/ui/radarr_ui/library/movie_details_ui.rs @@ -7,7 +7,7 @@ use ratatui::widgets::{Cell, Paragraph, Row, Wrap}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::{Credit, MovieHistoryItem, Release}; +use crate::models::radarr_models::{Credit, MovieHistoryItem, RadarrRelease}; use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, MOVIE_DETAILS_BLOCKS}; use crate::models::Route; @@ -380,7 +380,7 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .clone(), movie_details_modal.movie_releases.items.is_empty(), ), - _ => (Release::default(), true), + _ => (RadarrRelease::default(), true), }; let current_route = *app.get_current_route(); let mut default_movie_details_modal = MovieDetailsModal::default(); @@ -398,8 +398,8 @@ fn draw_movie_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { .unwrap_or(&mut default_movie_details_modal) .movie_releases, ); - let releases_row_mapping = |release: &Release| { - let Release { + let releases_row_mapping = |release: &RadarrRelease| { + let RadarrRelease { protocol, age, title, diff --git a/src/ui/radarr_ui/mod.rs b/src/ui/radarr_ui/mod.rs index 8f58912..d19fabc 100644 --- a/src/ui/radarr_ui/mod.rs +++ b/src/ui/radarr_ui/mod.rs @@ -9,8 +9,9 @@ use ratatui::Frame; use crate::app::App; use crate::logos::RADARR_LOGO; -use crate::models::radarr_models::{DiskSpace, DownloadRecord, Movie, RootFolder}; +use crate::models::radarr_models::{DownloadRecord, Movie}; use crate::models::servarr_data::radarr::radarr_data::RadarrData; +use crate::models::servarr_models::{DiskSpace, RootFolder}; use crate::models::Route; use crate::ui::draw_tabs; use crate::ui::radarr_ui::blocklist::BlocklistUi; diff --git a/src/ui/radarr_ui/root_folders/mod.rs b/src/ui/radarr_ui/root_folders/mod.rs index bff2797..381b37c 100644 --- a/src/ui/radarr_ui/root_folders/mod.rs +++ b/src/ui/radarr_ui/root_folders/mod.rs @@ -3,8 +3,8 @@ use ratatui::widgets::{Cell, Row}; use ratatui::Frame; use crate::app::App; -use crate::models::radarr_models::RootFolder; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, ROOT_FOLDERS_BLOCKS}; +use crate::models::servarr_models::RootFolder; use crate::models::Route; use crate::ui::styles::ManagarrStyle; use crate::ui::utils::layout_block_top_border; diff --git a/src/ui/radarr_ui/system/mod.rs b/src/ui/radarr_ui/system/mod.rs index 93c1a04..c46653e 100644 --- a/src/ui/radarr_ui/system/mod.rs +++ b/src/ui/radarr_ui/system/mod.rs @@ -12,8 +12,9 @@ use ratatui::{ }; use crate::app::App; -use crate::models::radarr_models::{QueueEvent, Task}; +use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock; +use crate::models::servarr_models::QueueEvent; use crate::ui::radarr_ui::radarr_ui_utils::{convert_to_minutes_hours_days, style_log_list_item}; use crate::ui::radarr_ui::system::system_details_ui::SystemDetailsUi; use crate::ui::styles::ManagarrStyle; @@ -90,7 +91,7 @@ pub(super) fn draw_system_ui_layout(f: &mut Frame<'_>, app: &mut App<'_>, area: } fn draw_tasks(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { - let tasks_row_mapping = |task: &Task| { + let tasks_row_mapping = |task: &RadarrTask| { let task_props = extract_task_props(task); Row::new(vec![ @@ -217,7 +218,7 @@ pub(super) struct TaskProps { pub(super) next_execution: String, } -pub(super) fn extract_task_props(task: &Task) -> TaskProps { +pub(super) fn extract_task_props(task: &RadarrTask) -> TaskProps { let interval = convert_to_minutes_hours_days(task.interval); let last_duration = &task.last_duration[..8]; let next_execution = diff --git a/src/ui/radarr_ui/system/system_details_ui.rs b/src/ui/radarr_ui/system/system_details_ui.rs index db4582a..cfc3c5c 100644 --- a/src/ui/radarr_ui/system/system_details_ui.rs +++ b/src/ui/radarr_ui/system/system_details_ui.rs @@ -6,7 +6,7 @@ use ratatui::Frame; use crate::app::context_clues::{build_context_clue_string, BARE_POPUP_CONTEXT_CLUES}; use crate::app::radarr::radarr_context_clues::SYSTEM_TASKS_CONTEXT_CLUES; use crate::app::App; -use crate::models::radarr_models::Task; +use crate::models::radarr_models::RadarrTask; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, SYSTEM_DETAILS_BLOCKS}; use crate::models::Route; use crate::ui::radarr_ui::radarr_ui_utils::style_log_list_item; @@ -108,7 +108,7 @@ fn draw_logs_popup(f: &mut Frame<'_>, app: &mut App<'_>) { fn draw_tasks_popup(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) { let help_footer = Some(build_context_clue_string(&SYSTEM_TASKS_CONTEXT_CLUES)); - let tasks_row_mapping = |task: &Task| { + let tasks_row_mapping = |task: &RadarrTask| { let task_props = extract_task_props(task); Row::new(vec![ diff --git a/src/ui/widgets/managarr_table.rs b/src/ui/widgets/managarr_table.rs index 7d348f5..eb9b5c9 100644 --- a/src/ui/widgets/managarr_table.rs +++ b/src/ui/widgets/managarr_table.rs @@ -146,7 +146,7 @@ where if self.highlight_rows { table = table - .highlight_style(Style::new().highlight()) + .row_highlight_style(Style::new().highlight()) .highlight_symbol(HIGHLIGHT_SYMBOL); } diff --git a/src/utils.rs b/src/utils.rs index 816f0fb..b5e98db 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -2,13 +2,25 @@ use std::fs::{self, File}; use std::io::{BufRead, BufReader, Seek, SeekFrom}; use std::path::PathBuf; use std::process; +use std::sync::Arc; +use std::time::Duration; +use anyhow::anyhow; +use anyhow::Result; use colored::Colorize; -use log::LevelFilter; +use indicatif::{ProgressBar, ProgressStyle}; +use log::{error, LevelFilter}; use log4rs::append::file::FileAppender; use log4rs::config::{Appender, Root}; use log4rs::encode::pattern::PatternEncoder; use regex::Regex; +use reqwest::{Certificate, Client}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use crate::app::{log_and_print_error, App, AppConfig}; +use crate::cli::{self, Command}; +use crate::network::Network; #[cfg(test)] #[path = "utils_tests.rs"] @@ -122,3 +134,126 @@ fn colorize_log_line(line: &str, re: &Regex) -> String { line.to_string() } } + +pub(super) fn load_config(path: &str) -> Result { + let file = File::open(path).map_err(|e| anyhow!(e))?; + let reader = BufReader::new(file); + let config = serde_yaml::from_reader(reader)?; + Ok(config) +} + +pub(super) fn build_network_client(config: &AppConfig) -> Client { + let mut client_builder = Client::builder() + .pool_max_idle_per_host(10) + .http2_keep_alive_interval(Duration::from_secs(5)) + .tcp_keepalive(Duration::from_secs(5)); + + if let Some(radarr_config) = &config.radarr { + if let Some(ref cert_path) = &radarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Radarr"); + client_builder = client_builder.add_root_certificate(cert); + } + } + + if let Some(sonarr_config) = &config.sonarr { + if let Some(ref cert_path) = &sonarr_config.ssl_cert_path { + let cert = create_cert(cert_path, "Sonarr"); + client_builder = client_builder.add_root_certificate(cert); + } + } + + match client_builder.build() { + Ok(client) => client, + Err(e) => { + error!("Unable to create reqwest client: {}", e); + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +} + +pub(super) fn create_cert(cert_path: &String, servarr_name: &str) -> Certificate { + match fs::read(cert_path) { + Ok(cert) => match Certificate::from_pem(&cert) { + Ok(certificate) => certificate, + Err(_) => { + log_and_print_error(format!( + "Unable to read the specified {} SSL certificate", + servarr_name + )); + process::exit(1); + } + }, + Err(_) => { + log_and_print_error(format!( + "Unable to open specified {} SSL certificate", + servarr_name + )); + process::exit(1); + } + } +} + +pub(super) fn render_spinner() -> ProgressBar { + let pb = ProgressBar::new_spinner(); + pb.enable_steady_tick(Duration::from_millis(60)); + pb.set_style( + ProgressStyle::with_template("{spinner:.blue}") + .unwrap() + .tick_strings(&[ + "⢀⠀", "⡀⠀", "⠄⠀", "⢂⠀", "⡂⠀", "⠅⠀", "⢃⠀", "⡃⠀", "⠍⠀", "⢋⠀", "⡋⠀", "⠍⠁", "⢋⠁", "⡋⠁", "⠍⠉", + "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⢈⠩", "⡀⢙", "⠄⡙", "⢂⠩", "⡂⢘", "⠅⡘", "⢃⠨", "⡃⢐", + "⠍⡐", "⢋⠠", "⡋⢀", "⠍⡁", "⢋⠁", "⡋⠁", "⠍⠉", "⠋⠉", "⠋⠉", "⠉⠙", "⠉⠙", "⠉⠩", "⠈⢙", "⠈⡙", "⠈⠩", + "⠀⢙", "⠀⡙", "⠀⠩", "⠀⢘", "⠀⡘", "⠀⠨", "⠀⢐", "⠀⡐", "⠀⠠", "⠀⢀", "⠀⡀", + ]), + ); + pb.set_message("Querying..."); + pb +} + +pub(super) async fn start_cli_with_spinner( + config: AppConfig, + reqwest_client: Client, + cancellation_token: CancellationToken, + app: Arc>>, + command: Command, +) { + config.verify_config_present_for_cli(&command); + app.lock().await.cli_mode = true; + let pb = render_spinner(); + let app_nw = Arc::clone(&app); + let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); + match cli::handle_command(&app, command, &mut network).await { + Ok(output) => { + pb.finish(); + println!("{}", output); + } + Err(e) => { + pb.finish(); + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +} + +pub(super) async fn start_cli_no_spinner( + config: AppConfig, + reqwest_client: Client, + cancellation_token: CancellationToken, + app: Arc>>, + command: Command, +) { + config.verify_config_present_for_cli(&command); + app.lock().await.cli_mode = true; + let app_nw = Arc::clone(&app); + let mut network = Network::new(&app_nw, cancellation_token, reqwest_client); + match cli::handle_command(&app, command, &mut network).await { + Ok(output) => { + println!("{}", output); + } + Err(e) => { + eprintln!("error: {}", e.to_string().red()); + process::exit(1); + } + } +}