Compare commits

..
466 Commits
Author SHA1 Message Date
Dark-Alex-17 003f80098a docs: Updated the pull request template 2026-09-30 00:11:56 -06:00
Dark-Alex-17 ad8a6743ab docs: Added documentation on how to configure Chaptarr in Managarr 2026-09-30 00:03:29 -06:00
Dark-Alex-17 c61404dc71 docs: Updated the contribution guidelines and PR template to sound more like AI is allowed but only when disclosed 2026-09-29 23:46:40 -06:00
Dark-Alex-17 a5b95c165a fix(readarr): send an empty images list when adding an author 2026-09-29 23:36:11 -06:00
Dark-Alex-17 8ec4ec018b fix(readarr): accept release searches wrapped in a filter summary envelope 2026-09-29 23:30:30 -06:00
Dark-Alex-17 642a5c0a87 fix(readarr): tolerate book payloads that omit the grabbed flag 2026-09-29 23:25:19 -06:00
Dark-Alex-17 9128e68d0a test(readarr): cover the CLI handler dispatch to each subcommand handler 2026-09-29 16:52:53 -06:00
Dark-Alex-17 ddc18ae21a docs: Updated the README to mention that Readarr has been sunset but Managarr is compatible with any APIs that are compatible with Readarr v1 2026-09-29 15:54:47 -06:00
Dark-Alex-17 4b8b9cde57 docs: Updated README with screenshots of Readarr 2026-09-29 14:24:00 -06:00
Dark-Alex-17 238348f6ec fix(radarr): render the movie overview one size down so the collection details stay visible behind it 2026-09-29 13:49:28 -06:00
Dark-Alex-17 37f697d1b4 docs: reformatted tables in README 2026-09-29 13:47:04 -06:00
Dark-Alex-17 a70019b88f Merge remote-tracking branch 'origin/main' into readarr-support
# Conflicts:
#	src/utils_tests.rs
2026-09-29 12:46:54 -06:00
Alex Clarke fa02b70bf0 Merge pull request #64 from solareon/fix/servarr_name
fix: passes servarr-name global switch to both the tui and cli
2026-09-29 10:54:20 -06:00
Solareon 76f9f4da9a test: remove disable-spinner to improve test coverage 2026-09-29 11:05:37 +02:00
Solareon 49be500b8c style: cargo fmt and nested if fix 2026-09-29 10:44:39 +02:00
Solareon 78918b6d88 test: ensure no servarr-name select first configured instance 2026-09-29 10:40:11 +02:00
Solareon 9b98e53b55 test: servarr-name with whitespace is trimmed properly 2026-09-29 10:31:01 +02:00
Solareon b799fe3865 test: unknown servarr-name fails properly 2026-09-29 10:21:00 +02:00
Solareon 0e869e1e98 test: ensure duplicate servarr names select correct instance 2026-09-29 10:05:37 +02:00
Solareon 196bc55f35 test: validate selected servarr-name type mismatch 2026-09-29 10:00:24 +02:00
Solareon e29bd67a3e test: happy path test for servarr-name 2026-09-29 08:55:04 +02:00
Dark-Alex-17 424ed62229 chore: update dependencies ahead of release 2026-09-28 16:02:28 -06:00
Dark-Alex-17 ab7542651b chore: bump GitHub Actions to Node 24 runtimes 2026-09-28 15:53:26 -06:00
Dark-Alex-17 a222f40616 fmt: addressed linter complaints 2026-09-28 14:49:53 -06:00
solareon 699e929ed2 fix(app/mod): use correct api for tab when starting on non default tab
Further testing of the servarr_name switch revealed an issue where selecting a server that didn't match the first tab caused the wrong api to be used. Now when a non-default tab is selected the correct api is used
2026-09-25 07:44:51 -04:00
solareon 5279d37ea2 fix: passes servarr-name global switch to both the tui and cli
servarr-name global switch was documented but not fully implemented in commit fd6fcfc. this commit fully implements the feature
2026-09-25 07:30:01 -04:00
Dark-Alex-17 a4b8a23424 docs: list the Readarr features in the README 2026-09-19 13:57:49 -06:00
Dark-Alex-17 f2ee1e71f6 feat: show sizes under a gigabyte in megabytes
Every size cell converted bytes to gigabytes and formatted the float in
place, so anything below a gigabyte rendered as 0.00 GB. Books and
tracks are almost always that small, which made the Size columns in
Readarr and Lidarr useless.

Replace convert_to_gb and convert_f64_to_gb with one format_size helper
that renders gigabytes at or above 1 GiB and megabytes below it, at the
precision each site already used. Every render site across the four
servarr trees now calls it, including the two movie and episode detail
strings built on the network side. Values at or above a gigabyte render
byte for byte as before.

Give the Readarr book statistics and book file fixtures and the Lidarr
album statistics fixture realistic sub-gigabyte sizes so the megabyte
path is pinned by the committed snapshots.
2026-09-18 21:44:22 -06:00
Dark-Alex-17 bdeaf6ae35 test: prove the track, episode and movie row styles reach the rendered buffer
The row-style decorators were unit-tested as pure functions and the populated test apps do carry a downloading queue entry, but text snapshots discard colour, so nothing proved the draw functions actually call the decorator with the live downloads list. Passing an empty slice instead left every test green.

Render the lidarr tracks table, the sonarr episodes table and the radarr movies table into a Buffer and assert the probe row's foreground: downloading_style with the queue populated, missing_style once the queue is emptied. The probe is the second row so the selection highlight never masks its style.
2026-09-17 18:44:34 -06:00
Dark-Alex-17 35ed3f359d refactor: share one DownloadStatus enum across the servarr models
DownloadStatus was declared four times, byte for byte identically, in
radarr_models, sonarr_models, lidarr_models, and readarr_models: the same
variants, the same derive list, and the same serde, strum, and display_style
attributes. Every servarr's queue endpoint reports the same status
vocabulary, so there is nothing servarr specific about the type.

Move it into servarr_models, preserving that file's alphabetical ordering
(between DiskSpace and IndexerSettings), and drop the four copies. The
DownloadRecord types keep their per-servarr shapes and now reference the
shared enum.

The two enum tests, test_download_status_display and
test_download_status_to_display_str, were likewise duplicated in the four
per-servarr model test files. Keep one copy of each in servarr_models_tests
alongside the other shared enum display tests and delete the rest. The
per-servarr DownloadRecord fallback deserialization tests stay where they
are since they exercise each servarr's own record type.

Behavior is unchanged. Every remaining edit is an import path update.
2026-09-17 18:32:51 -06:00
Dark-Alex-17 040935178a test(sonarr): give the download record and episode fixtures distinct ids
The shared download record held id and episode id both equal to 1, and
the shared episode held id and episode file id both equal to 1, so
reading the wrong one of those fields could not fail any test that uses
them. Move the download record to id 9 and the episode to id 11, keeping
the download record's episode id equal to the episode's id so the
downloading episode still matches, and move the episode file to id 7
together with the episode's episode file id so the season view still
finds its quality and size. Series id, season number, and episode number
stay at 1. The handler tree keeps its own copy of these fixtures; update
it identically, along with the JSON twins in the network tests and the
seven handler assertions that named the old ids. The two get episodes
tests that asserted the sorted table now spell out the sorted order,
since the fixture episode no longer sorts first by id.
2026-09-17 18:22:31 -06:00
Dark-Alex-17 4f78a5940f test(lidarr): give the track and history fixtures distinct ids
The shared track held id, artist id, track file id, and album id all
equal to 1, and the shared history item held id, album id, artist id,
and track id all equal to 1, so reading the wrong one of those fields
could not fail any test that uses them. Move the track to id 5, artist
id 3, and track file id 7 (with the shared track file moved to id 7 so
the album view still finds its quality and audio info), and move the
history item to id 13, artist id 3, and track id 5. Both album ids stay
at 1 so the fully populated album view still shows its track as
downloading. Update the seven assertions that named the old ids.
2026-09-17 18:16:17 -06:00
Dark-Alex-17 9822597b56 fix(radarr): model the movie status as an enum
Movie.status and AddMovieSearchResult.status were bare Strings compared
against the literal "released" in the row-style decorator and in the
movie details style function. They now carry a MovieStatus enum with the
five values Radarr actually sends (tba, announced, inCinemas, released,
deleted), shaped like MinimumAvailability and DownloadStatus, and guarded
with from_json_or_default so an unrecognised status degrades to Tba
instead of failing the whole fetch.

Radarr never sends "Downloaded" as a movie status; its web UI derives
that label from released plus hasFile. Every fixture that carried
"Downloaded" now holds released, the value it was standing in for, so
none of them degrade to the default through the guard.

The status is never rendered as text in the Radarr tree, so no snapshot
changes; the CLI JSON output is unchanged since the enum serializes to
the same camelCase strings. The style_from_download_status tests now
exercise the unreleased arm, which previously had no case, in place of a
duplicated unmonitored-missing row.
2026-09-17 18:06:37 -06:00
Dark-Alex-17 c42a46c7ca fix: page up and down stop at the table edges instead of wrapping
A page is 20 rows and the index wrapped modulo one less than the row
count, so the last row was unreachable by paging, 21-row and 5-row tables
did nothing on page down from the first row, and 4-row tables cycled
0, 2, 1 and never reached row 3. Page down now clamps to the last row and
page up clamps to the first.

With no division left, a single-row table is safe, so the early return
only guards the empty table again, matching scroll_to_top and
scroll_to_bottom.
2026-09-17 17:38:37 -06:00
Dark-Alex-17 1d936c5cb5 fix(radarr): serialize the credit type under the same key it is read from
Radarr sends and accepts the credit type as "type" in both directions,
but the field was only renamed for deserialization, so serializing a
Credit emitted "creditType" and a to_value round trip could not be read
back. Credit is only deserialized in production, so this affects tests
and any future serialization of the record, not the live API traffic.
2026-09-17 15:49:12 -06:00
Dark-Alex-17 7b6d595397 fix(radarr): model the download status as a DownloadStatus enum
Radarr was the only tree still holding DownloadRecord.status as a bare
String and comparing it against literals. It now carries its own
DownloadStatus enum, shaped exactly like the Sonarr, Lidarr and Readarr
copies, and is guarded with from_json_or_default so an unrecognised
status degrades to Unknown instead of failing the whole queue fetch.

The row-style decorator and get_movie_status compare against the enum
variants. The status cell is never rendered in the Radarr tree, so no
snapshot changes; the CLI JSON output is unchanged for every real value
since the enum serializes to the same camelCase strings.
2026-09-17 15:46:49 -06:00
Dark-Alex-17 25da032f02 test(lidarr): give the download record fixture distinct album and artist ids
The shared download record held id, album id, and artist id all equal to
1, so reading the wrong one of the three could not fail any test that
uses the fixture. Move artist id to 3 and id to 9, keeping album id at 1
so the fully populated album view still shows its track as downloading,
and update the three handler assertions that named the old id.
2026-09-17 15:36:27 -06:00
Dark-Alex-17 e9c323c4e8 test(sonarr): cover the episode row style branches
decorate_with_row_style had no unit tests, so every one of its seven
return sites could be swapped for another style without a failure. Add
one test per branch, including the ordering cases where the download
match precedes the monitored guard and the monitored guard precedes the
air date check, using a local download fixture with distinct episode,
other-episode, and download ids.
2026-09-17 15:30:16 -06:00
Dark-Alex-17 289abbd8eb test(readarr): populate the book fixture with statistics
The shared Book fixture left statistics as None, so every author details
snapshot rendered the missing-statistics N/A cells and the populated
branch of the Files and Size columns was only exercised by one test.
Give the fixture distinct, non-default counts and a size that formats to
a value different from the dedicated statistics test.
2026-09-17 15:19:16 -06:00
Dark-Alex-17 a68de41025 fix: fall back to the default when a servarr returns an unknown enum value
A Servarr build can emit an enum value this app has no name for, either
as a string it does not recognise or as a bare integer for a value the
build itself cannot name. Until now any such value in a response failed
the whole payload, so one unexpected artist status, download status,
task name, series type, minimum availability, credit type, or security
setting emptied the entire view with "Failed to parse response!".

Every enum-typed field on a struct the app only ever deserializes and
displays now uses from_json_or_default, so an unrecognised value
degrades to that enum's default and the rest of the record is kept.
Known values still map exactly as before and serialization is untouched.

Fields on request bodies (Add*Body, Add*Options, Edit*Params, the root
folder bodies) are deliberately left alone: those structs are only ever
serialized, so the attribute would be meaningless there. No candidate
struct is round-tripped: every edit and toggle in the app re-fetches
the raw JSON value and patches it by key, so the fallback can never be
written back to a server as a real setting.

The one Option field, the security config's authentication_required,
also carries serde(default), because deserialize_with on an Option turns
a missing key into a hard error. An unknown value there yields None.

Verified by mutation. Removing the attribute from one field per tree
fails exactly that field's test and nothing else. Dropping default from
the Option field fails only the missing-key test with "missing field
authenticationRequired"; dropping deserialize_with from it fails only
the unknown-value test. All 21 new fallback tests fail against the
unguarded code with "invalid type: integer 2, expected string or map".
2026-09-17 15:08:52 -06:00
Dark-Alex-17 9d0973352f feat(lidarr): highlight tracks whose album is downloading or awaiting import
Lidarr queues whole albums rather than individual tracks, so a track row
is styled from the queue entry whose albumId matches the track's albumId,
mirroring the Sonarr episode row logic. Tracks that already have a file
keep the downloaded style regardless of the queue.
2026-09-17 14:50:27 -06:00
Dark-Alex-17 76c490f45b fix: show N/A instead of zeros when a season, album or book has no statistics
The season, album and book tables rendered 0/0 and 0.00 GB whenever the
API omitted the statistics object for a row, presenting fabricated counts
as real. The row colour already treated that case as indeterminate; the
cell text now does too, rendering N/A for both cells, matching what these
tables already do for a missing release date.

Sonarr's season row is restructured from a destructure-with-default to the
same Option-aware map_or_else shape lidarr and readarr use.

The readarr test fixture book has no statistics, so its existing author
details snapshots change from the fabricated zeros to N/A on exactly that
row.
2026-09-17 14:42:54 -06:00
Dark-Alex-17 9b4b3003b5 test: give the string input and table filter property tests real assertions 2026-09-17 14:33:16 -06:00
Dark-Alex-17 d678f5777a test: cover the status style branches for track and episode details 2026-09-17 14:22:47 -06:00
Dark-Alex-17 87edf1fb9e test: wire the handler property tests and make the proptest recipe run them
handler_proptest.rs was committed without ever being declared as a module, and it imported a list_size strategy that did not exist, so it never compiled. The proptest recipe filtered on a test name substring that matched no test.

Declare the module, add the two strategies it needs, and pass both property test filters to libtest so the recipe runs the ui and handler property tests.
2026-09-16 14:28:30 -06:00
Dark-Alex-17 d3a959d2dd fix: stop paging up or down on a single row table from crashing
page_up and page_down wrap the selection with a modulus of items_len - 1,
which is a remainder by zero when the table holds exactly one row. Ctrl-U
or Ctrl-D on such a table panicked the TUI. Both now return early for
tables of one row or fewer, mirroring the existing empty-table guard;
tables of two or more rows keep their existing paging arithmetic.
2026-09-16 12:25:00 -06:00
Dark-Alex-17 1857d5ba15 feat(readarr): show the author overview in the add author prompt
The add author confirmation popup previously left the space above the
root folder field blank. It now renders the selected search result's
overview there, in a fixed six line area matching the Radarr add movie
prompt, so the user can confirm they picked the right author before
adding. An author without an overview keeps the same layout.

The fully populated search result fixture gains a long overview so the
snapshot proves the paragraph is clipped to six lines.
2026-09-16 12:00:27 -06:00
Dark-Alex-17 86b91ce6dd fix: keep history readable when an event type is unrecognised
A single history record whose event type the Servarr build has no name
for made the entire history response fail to decode, so the view died
with "Failed to parse response!" and showed nothing at all.

The event type is stored as an integer and is only serialized as a
camelCase string when the build can name it. For values it cannot name,
it is emitted as a bare JSON number instead, which is a type mismatch
rather than merely an unknown name. Serde's other attribute does not
help: it is rejected outright unless it sits on the last variant, and
even there it only catches unrecognised strings, leaving a number to
fail exactly as before.

from_json_or_default buffers the field and falls back to the type's
default for any input the enum does not recognise, whether that is an
unknown string, a number, or any other JSON value. It is applied to the
event type of all four history item types. Known values still map
exactly as they did, and serialization is untouched.

Verified against a live Readarr carrying such a record: the history
listing failed to decode before the change, and afterwards lists every
record, rendering the unnamed one as Unknown.

Verified by mutation. Seven mutations all fail the new suite: dropping
the fallback, discarding the decoded value, removing the attribute from
each of the four history items, and renaming a variant on serialize
only. The five per-tree and serialize-only mutations were each invisible
to every pre-existing test.
2026-09-11 12:27:53 -06:00
Dark-Alex-17 9dbd985506 fix(readarr): attach editions when toggling book monitoring
Toggling book monitoring failed with an HTTP 500, "Value cannot be null.
(Parameter 'source')", every time, in both the CLI and the TUI.

GET /book/{id} omits the editions field entirely, but PUT /book/{id}
requires it, so round-tripping Readarr's own response back as an update
was always rejected. Editions are served separately from
GET /edition?bookId=, so fetch them and attach them to the body before
the PUT.
2026-09-11 11:27:27 -06:00
Dark-Alex-17 fbf711f428 test(lidarr): extract and cover the track row style branch
The snapshot harness renders to a plain string, so colour and text modifiers never reach the assertion. The track row's downloaded and missing arms were therefore invisible to the suite: swapping either arm for the other, or inverting the has_file condition, all left the full run green.

Move the branch into decorate_track_row_with_style, mirroring the row decorators already extracted in the other library detail views, and unit test both arms directly against Row equality. All three mutations now fail the suite.
2026-09-10 16:19:25 -06:00
Dark-Alex-17 db8e97a700 fix(sonarr): stop marking seasons fully downloaded when no episodes are known
A monitored season rendered in the fully downloaded colour whenever Sonarr returned no statistics for it, and whenever it genuinely had zero known episodes. Both cases compared zero against zero and short circuited to the downloaded style, claiming the user had every episode of a season that was only announced, or that had never been scanned.

Seasons whose statistics are absent now render as indeterminate, matching the series row and the equivalent album and book rows in the other trees, and the downloaded style now requires a positive episode count.
2026-09-10 15:09:40 -06:00
Dark-Alex-17 c069ed4a9b test(sonarr): extract and cover the season row style branches
Row styling is invisible to the snapshot harness, because render_to_string_with_app returns a plain String and discards colour and modifiers. The season row style branch was therefore entirely uncovered, and any arm could be swapped for another without a single test noticing.

decorate_season_row_with_style now holds that branch, matching decorate_series_row_with_style in the library view. The extraction is behaviour preserving: the branch is token identical to the one it replaces, the statistics fallback resolves the same way, and no snapshot changed. The season row deliberately has no positive episode count guard, so a season without statistics falls back to zero of zero episodes and reads as downloaded; a test now pins that.

Verified by mutation. Against the previous tree all seven mutations left the suite green: replacing each of the unmonitored, downloaded, unreleased and both missing arms, flipping the episode file count comparison, and inverting the monitored check. Against the new tests every one of those seven fails.
2026-09-10 13:52:17 -06:00
Dark-Alex-17 3227495947 test(lidarr): extract and cover the album row style branches
Row styling is invisible to the snapshot harness, because render_to_string_with_app returns a plain String and discards colour and modifiers. The album row style branch was therefore entirely uncovered, and any arm could be swapped for another without a single test noticing.

decorate_album_row_with_style now holds that branch, matching decorate_artist_row_with_style in the library view and decorate_book_row_with_style in the Readarr author details view. The extraction is behaviour preserving: the branch is token identical to the one it replaces, and no snapshot changed.

Verified by mutation. Against the previous tree all eight mutations left the suite green: replacing each of the unmonitored, downloaded, unreleased, both missing and indeterminate arms, flipping the track file count comparison, and inverting the monitored check. Against the new tests every one of those eight fails.
2026-09-10 13:51:25 -06:00
Dark-Alex-17 243b22a426 fix: keep a space between overview words that span a line break
The collapsed one-line overview on the Lidarr artist, Sonarr series, and Readarr author details views replaced every line break and tab with an empty string. Words on either side of a paragraph break fused together, rendering as "20th century.She was born in Ohio".

Collapse each run of whitespace to a single space instead, and trim the result. Handling a whole run at a time keeps a Windows line ending, an indented paragraph, or a space that already precedes a line break from producing two spaces.

The overview popups are unaffected: they receive the raw text and keep their paragraph breaks.
2026-09-10 13:27:02 -06:00
Dark-Alex-17 caafe1602e test(radarr): name the updates loading test after its neighbours
The Radarr system details tests prefix their loading cases with renders_. This one was copied verbatim from the other trees and did not match. Rename it and its snapshot to fit, leaving the sibling trees alone.
2026-09-10 13:07:54 -06:00
Dark-Alex-17 65ba37c09a fix: parse the log level from the unscrolled log text
draw_logs read the level from the log line's Display output, which drops the first offset characters. Once a log line is scrolled, the field at index 1 is no longer the level: past the timestamp it yields the logger name and colours the row wrong, and past the final delimiter the index is out of bounds and panics, taking the TUI down.

Parse log.text instead, matching what draw_logs_popup already does. The displayed text is unchanged.
2026-09-10 13:06:04 -06:00
Dark-Alex-17 c525fb9a4e feat(radarr): render the movie overview popup at the large size
The movie overview was the only overview popup drawn at the small size;
the sonarr, lidarr and readarr overview popups all use the large size.
The small size wrapped longer overviews sooner and gave the text roughly
a quarter of the space the other trees allow.

Draw it at the large size to match them. The wider popup now covers the
collection details behind it, as the other overview popups do.
2026-09-10 12:40:59 -06:00
Dark-Alex-17 e79cc7a12d fix(radarr): show a loading block while the updates popup refreshes
The updates popup only checked whether its text was empty, so during a
refresh it kept rendering the previously fetched text. Opening the system
updates view dispatches a request for the update notes, so the popup could
sit on stale content until the response landed.

Guard on the loading flag as well, matching the sonarr, lidarr and readarr
updates popups, and add the loading-state test those trees have so both
halves of the condition are exercised. The pre-existing empty-updates test
never reaches the second half on its own.
2026-09-10 12:35:56 -06:00
Dark-Alex-17 12dbbf0b1b test: give the statistics fixtures distinct file and total counts
The artist, album and season statistics fixtures set the file count equal to the total count, so the columns rendering the pair showed the same number twice and swapping the two fields changed nothing. The JSON fixtures move with them so the round trip assertions still hold.
2026-09-10 01:10:41 -06:00
Dark-Alex-17 1a5e5c3145 test: give the delete flags distinct values where params are asserted
The delete params tests set the delete files flag and the list exclusion flag both true, so swapping the two fields in the params builder produced identical params and no test noticed. The tests that assert a reset still set both true, since that is what proves both flags are cleared.
2026-09-10 01:04:07 -06:00
Dark-Alex-17 5fa521ba7f test: cover indexer settings drawn over the indexers table
IndexersUi dispatches to IndexerSettingsUi, but no test rendered that route through the parent, so deleting the dispatch arm in radarr, sonarr and lidarr left the whole suite green.
2026-09-10 00:57:55 -06:00
Dark-Alex-17 f7c67ad58b test(radarr): cover the updates popup empty state
Radarr was the only tree without a test rendering draw_updates_popup with no update text, leaving its loading branch unreachable from the suite. Forcing the guard true went undetected.
2026-09-10 00:53:07 -06:00
Dark-Alex-17 7e8072133c test: pin the updates popup loading guard in sonarr and lidarr
draw_updates_popup shows the loading block when update text is already present but a refresh is still in flight. Both existing tests fed empty update text, so they only exercised the first half of the guard and dropping the is_loading conjunct went undetected.
2026-09-10 00:49:42 -06:00
Dark-Alex-17 e622be3288 test: assert delegated handlers pop exactly one navigation stack frame
test_handler_delegation! asserted only that the current route equalled the
base block after Esc. When the delegated block was itself the base block the
stack read [base, base], so that assertion held whether or not the sub-handler
popped, and the case asserted nothing.

Capture the navigation stack depth before delegating and assert it drops by
exactly one, which also catches a handler that pops and then re-pushes. Depth
is read through a test-only accessor on App; the navigation_stack field stays
private.

Main tab blocks clear errors on Esc rather than popping, by design, so they
cannot be probed this way at all. Drop them from the delegation value lists;
the remaining values in each list already exercise the same dispatch arm.
2026-09-10 00:41:07 -06:00
Dark-Alex-17 08018e4457 test(readarr): give the quality and metadata profile fixtures distinct values
ReadarrData::test_default_fully_populated seeded both profile maps with the same id and the same name, and gave the author fixture the same id for both profiles. The library table renders the two profiles in adjacent columns, so both read Standard and swapping the maps produced identical output.

The maps now use the disjoint ids and the vocabulary the root folder and add author tests already use, so the columns read Standard and Comprehensive.

Verified by swapping the two map lookups where the authors table is built. That swap survived the whole suite before this change and is now caught by sixteen snapshots.
2026-09-09 17:03:28 -06:00
Dark-Alex-17 78f5045844 test: pin extract_task_name against a real selected task
Both tests seeded a single default task and asserted the extracted name equalled the default task name, so a body that ignored the table entirely and returned the default satisfied them. Radarr and Readarr already seed distinct named tasks and select one; these two now follow that shape.

Verified by replacing both bodies with a bare default task name. The Lidarr test could not have failed before, since it compared the default against itself, and the run confirms it is the only Lidarr test that catches the change. The Sonarr body was already caught by the two start task prompt tests, so there the dedicated test is strengthened rather than newly load bearing.
2026-09-09 17:00:49 -06:00
Dark-Alex-17 e51b9d72b1 test(radarr): pin which table build_add_movie_body reads from
The handler fixtures for a collection movie and a searched movie both carried tmdb_id 1234 and the title Test, so the two branches of build_add_movie_body were value equivalent. The builder test also populated only the table its branch needed, so swapping the branches was caught by an unwrap panic on the empty table rather than by any assertion on the values.

The searched movie fixture now has its own identity, and the builder test seeds both tables so the branch is chosen against real competing data. Swapping the branches now fails with a value diff naming the wrong tmdb_id and title.

Only the handler copy of the fixture is changed. The network copy deliberately shares tmdb_id 1234 with the movie fixture, which is what drives the already in library marker in the add movie snapshots, so changing it there would have silently removed that marker.
2026-09-09 16:55:28 -06:00
Dark-Alex-17 b9d96ce235 test(readarr): give each history fixture row a distinct quality
Two of the three rows carried the quality EPUB, so sorting by quality left them tied and any reordering confined to that pair was invisible. The rows now use EPUB, AZW3 and MOBI, matching the vocabulary the history UI fixture already uses.

This one is defensive: no natural mutation of the quality comparator flipped from surviving to failing, because two distinct values already separated the cross-field cases. The tie is removed on principle rather than to close a demonstrated hole.
2026-09-09 16:46:50 -06:00
Dark-Alex-17 3e414f9f0c test: give the CLI indexer settings tests distinct field values
The Radarr, Sonarr and Lidarr tests for editing all indexer settings expected every numeric field to be 1 and returned every previous field as 2. Because the fields were mutually equal, transposing any two of availability_delay, maximum_size, minimum_age, retention and rss_sync_interval in the CLI parameter builder produced an identical request and no test could tell. Radarr additionally expected both boolean flags to hold the same value, hiding a transposition of those two.

Readarr already used distinct values and was the model followed here.

Verified by transposing minimum_age and retention in all four CLI parameter builders at once: before this change only the two Readarr tests failed, while Radarr, Sonarr and Lidarr stayed green. After it, the same mutation is caught by five tests across the three trees.
2026-09-09 16:43:25 -06:00
Dark-Alex-17 f99d4f61f4 test: give the tree-local IndexerSettings fixtures distinct field values
Sonarr and Lidarr each keep their own copy of the shared fixture, both with minimum_age and retention set to 1, so transposing those two fields stayed invisible in either tree. Radarr built its fixture from IndexerSettings::default(), leaving availability_delay, maximum_size, minimum_age and retention all 0: four mutually transposable fields, none of which could distinguish a threaded value from a hardcoded 0.

The no-op guard test seeded IndexerSettings::default() as its sentinel, which cannot show whether the guard held or the field was simply never written. It now seeds a distinct stale_indexer_settings(), mirroring the Readarr convention.

Verified by transposing minimum_age and retention in the Lidarr indexer settings UI, now caught by its snapshot, and by inverting the Radarr no-op guard, now caught by both GetAllIndexerSettings tests.
2026-09-09 16:38:36 -06:00
Dark-Alex-17 d08f7d4b4e test: give the shared IndexerSettings fixture distinct field values
minimum_age and retention were both 1, so transposing them in any code that reads both rendered identically and no test could tell the difference. id was also 1, which cannot distinguish a correctly threaded value from a hardcode.

Verified by transposing minimum_age and retention in the Sonarr indexer settings UI: that mutation survived the full suite before this change and is now caught by the settings snapshot.
2026-09-09 16:31:05 -06:00
Dark-Alex-17 cef1ad13af test(readarr): pin table search, filter and selection behaviour
Readarr handler tests never asserted which block the search and filter keys push, so transposing searching_block and filtering_block in a TableHandlingConfig went undetected. History already had both tests; add the five missing search key tests and the three missing filter key tests, covering the authors, books, author history, editions and book history tables. The books and editions tables configure no filtering_block, so they get no filter test.

No test moved a table or list selection off index 0 either, which made current_selection() and items[0] indistinguishable. Select a non-zero index in the tests covering every handler that reads a selected row, adding a decoy entry at index 0 so existing expected values stay meaningful. The profile, monitor and root folder lists inside the add author, edit author and add root folder prompts are a separate gap that a table-only sweep misses, so those are covered too, as are the book files, editions and releases tables held on the book details modal.

Several fixtures also asserted against a default value, so the expected result was indistinguishable from an unset field. The add author, edit author and add root folder bodies now expect non-default monitor options, and the quality and metadata profile ids are drawn from disjoint id pairs so the two lookups cannot be transposed.
2026-09-09 16:22:51 -06:00
Dark-Alex-17 f141e53f30 test(lidarr): pin table search, filter and selection behaviour
The search and filter blocks a table configures were asserted nowhere, so
transposing them was invisible. Selections were also left at index 0 in
every test, which made current_selection() and items[0] indistinguishable,
including for the profile lists inside the edit and add prompts.
2026-09-09 15:51:10 -06:00
Dark-Alex-17 18a50ab97c test(sonarr): pin table search, filter and selection behaviour
Sonarr handler tests never asserted which block the search and filter keys
push, so transposing searching_block and filtering_block in a
TableHandlingConfig went undetected. Add a search key test for each of the
six searchable tables and a filter key test for each of the four filterable
ones, covering library, history, series details and season details.

No test moved a table or list selection off index 0 either, which made
current_selection() and items[0] indistinguishable. Select a non-zero index
in the tests covering every handler that reads a selected row, adding a
decoy entry at index 0 so existing expected values stay meaningful. The
modal list selections in the add and edit series prompts are a separate
gap that a table-only sweep misses, so those are covered too.

Several fixtures also asserted against a default value, so the expected
result was indistinguishable from an unset field. The system tasks now use
two distinct non-default task names, the season monitoring and manual
search expectations use distinct non-zero ids, and the add and edit series
bodies expect a non-default series type and monitor with distinct quality
and language profile ids so the two lookups cannot be transposed.
2026-09-09 11:25:34 -06:00
Dark-Alex-17 4cbec79334 test(radarr): pin table search, filter and selection behaviour
Radarr handler tests never asserted which block the search and filter keys
push, so transposing searching_block and filtering_block in a
TableHandlingConfig went undetected. Add a search key and a filter key test
for each of the three handlers that configure them: library, collections and
history.

No test moved a table or list selection off index 0 either, which made
current_selection() and items[0] indistinguishable. Select a non-zero index
in the tests covering every handler that reads a selected row, enriching
fixtures with a second, distinct entry where only one existed.

Several fixtures also asserted against a default value, so the expected
result was indistinguishable from an unset field. The system details tasks
now use two distinct non-default task names, and the add movie body expects
a non-default monitor and minimum availability.
2026-09-09 10:47:07 -06:00
Dark-Alex-17 c9244b0459 feat(radarr): make the collection movie overview popup scrollable
A collection movie's overview renders in a popup that does not scroll,
so a long overview overflows with no way to read the rest. The popup now
scrolls.

The popup owns its own ScrollableText, built from the selected collection
movie's overview when it opens and torn down on Esc so a reopened popup
starts at the top. Scrolling mutates only that owned state. The
background renders the Collection record's overview and the movies
table, neither of which carries a scroll offset, so they cannot scroll
along with the popup. The popup also gains the loading and absent
fallbacks the rest of the tree already has.

Give the collection movie fixture an overview distinct from its
collection's, and a multi-paragraph one, so the snapshots can tell the
two records apart and a scrolled popup is distinguishable from an
unscrolled one.
2026-09-08 22:07:22 -06:00
Dark-Alex-17 b3ce15a564 feat(sonarr): add a scrollable overview popup to series details
A series' overview renders as one line of a description box that does
not scroll, so a long overview overflows with no way to read the rest.
Pressing v on the series details Seasons tab now opens a popup holding
the full overview.

The popup owns its own ScrollableText, built fresh from the series'
overview when it opens and torn down on Esc so a reopened popup starts
at the top. Scrolling mutates only that owned state. The background
paragraph renders the Series record's overview, which carries no scroll
offset, so it cannot scroll along with the popup.

Cite the existing view keybinding from the series details context clues
and give the popup its own clue set so it does not inherit the hints of
the view underneath.

Enrich the shared series fixture to a multi-paragraph overview, and the
raw response fixture that encodes the same series, so a scrolled popup
is distinguishable from an unscrolled one.
2026-09-08 21:31:25 -06:00
Dark-Alex-17 ca04498717 feat(lidarr): add a scrollable overview popup to artist details
An artist's overview renders as one line of a description box that does
not scroll, so a long overview overflows with no way to read the rest.
Pressing v on the artist details Albums tab now opens a popup holding
the full overview.

The popup owns its own ScrollableText, built fresh from the artist's
overview when it opens and torn down on Esc so a reopened popup starts
at the top. Scrolling mutates only that owned state. The background
paragraph renders the Artist record's overview, which carries no scroll
offset, so it cannot scroll along with the popup.

Cite the existing view keybinding from the artist details context clues
and give the popup its own clue set so it does not inherit the hints of
the view underneath.

Enrich the shared artist fixture to a multi-paragraph overview, and the
blocklist response fixtures that embed the same artist, so a scrolled
popup is distinguishable from an unscrolled one.
2026-09-08 21:05:50 -06:00
Dark-Alex-17 27c458307f feat(readarr): add a scrollable overview popup to author details
An author's overview renders as the last line of a description box that
does not scroll, so a long overview overflows with no way to read the
rest. Pressing v on the author details Books tab now opens a popup
holding the full overview.

The popup owns its own ScrollableText, built fresh from the author's
overview when it opens and torn down on Esc so a reopened popup starts
at the top. Scrolling mutates only that owned state. The background
paragraph renders the Author record's overview, which carries no scroll
offset, so it cannot scroll along with the popup.

Add a view keybinding bound to v, cite it from the author details
context clues, and give the popup its own clue set so it does not
inherit the hints of the view underneath.
2026-09-08 18:05:52 -06:00
Dark-Alex-17 ee6035c75d test(readarr): cover the top level UI dispatch for Readarr
The top level ui() dispatch had no Readarr coverage, so removing its match arm let every test in the suite pass while the Readarr views rendered nothing at all.

Mirrors the Radarr set, which is the most complete of the three: it is the only one that also covers the notification failure popup.
2026-09-08 16:18:00 -06:00
Dark-Alex-17 3fd0a61737 fix: show confirmation prompt clues on delete prompts
Delete confirmation prompts had no arm in any of the context clue providers, so they fell through to the main tab's contextual help and displayed the library tab's hints, such as add, sort and edit, while the user was looking at a delete confirmation.

Map the delete block arrays in all four servarrs to the shared confirmation prompt clues, matching how the other prompts and selection modals are already wired.
2026-09-08 16:07:19 -06:00
Dark-Alex-17 720584ea17 fix: drop redundant references in format arguments
The borrows were passed straight to format!, which already takes its
arguments by reference. Clears the last of the compiler warnings.
2026-09-08 15:56:38 -06:00
Dark-Alex-17 59346973fc feat(readarr): add the indexers UI
Render the indexers table with its delete and test popups, the edit
indexer prompt with separate torrent and usenet field grids, the prompt
for configuring all indexer settings, and the test all indexers results
table. All four are dispatched from the Readarr UI.

Extract indexer_flag_text, decorate_test_result_row_with_style and the
two prompt highlight builders as named functions so their branches can
be unit tested. Rendering to a string discards colour, so a snapshot
cannot tell a success style from a failure style.
2026-09-08 10:38:52 -06:00
Dark-Alex-17 a7178a481b feat(readarr): add the indexers handlers
Add IndexersHandler and its three sub-handlers, EditIndexerHandler, IndexerSettingsHandler and TestAllIndexersHandler, and dispatch to them from ReadarrHandler.

IndexersHandler::accepts ORs in all three children, so the parent and the children land together. Splitting would leave eighteen blocks accepted but inert, with Esc falling through to handle_clear_errors instead of popping.

TestAllIndexersHandler::accepts compares the block for equality rather than consulting an array, since TestAllIndexers appears in no block array.
2026-09-08 10:10:25 -06:00
Dark-Alex-17 70441259a0 feat(readarr): add the system UI
The system tab renders the tasks table, the queued events table and the log list, and overlays the log details, tasks, queued events, task start confirmation and updates popups.

SystemUi and SystemDetailsUi ship together because SystemUi::accepts covers every block that only SystemDetailsUi draws. Landing them apart would advertise five blocks that nothing renders.

Log level extraction is pulled into a named function so it can be unit tested directly: the level only ever reaches the output as a colour, and the rendered snapshots are plain strings that discard it.
2026-09-08 09:51:55 -06:00
Dark-Alex-17 24c5dedd0c feat(readarr): add the system handlers
SystemHandler owns the System tab: tab movement, the refresh key, and the
five keys that open the queued events, logs, tasks, and updates views. It
delegates every system detail block to SystemDetailsHandler, which scrolls
the log, task, queued event, and update views and drives the start task
confirmation prompt.

Both land together because SystemHandler::accepts covers the system detail
blocks that only SystemDetailsHandler implements, so landing the parent
alone would accept those blocks while doing nothing with them.
2026-09-08 09:33:26 -06:00
Dark-Alex-17 58fe7d79ae feat(readarr): add the root folders UI
Renders the root folders table, the delete prompt and the add root
folder modal. The modal conveys its current step purely through
styling, which rendered output discards, so the step highlights are
computed by a separate function whose branches are asserted directly.
2026-09-08 09:15:37 -06:00
Dark-Alex-17 5e1f3bb7e3 feat(readarr): add the add root folder handler 2026-09-04 12:22:35 -06:00
Dark-Alex-17 a4a3c77586 feat(readarr): add the root folders handler 2026-09-04 12:11:00 -06:00
Dark-Alex-17 ee07a0289f feat(readarr): add the history UI
Renders the history table, its sort, search and filter prompts, both
empty result popups and the item details view. Snapshots cover a
non-default selection so that reading a row from a fixed index rather
than the current selection cannot pass unnoticed.
2026-09-04 09:49:36 -06:00
Dark-Alex-17 91c2613e65 feat(readarr): add the downloads UI
Render the Readarr downloads table with the cancel download prompt and
the update downloads prompt, and dispatch it from ReadarrUi so the tab
is reachable.

The snapshot suite covers every block in DOWNLOADS_BLOCKS plus loading
and empty states. A second set of snapshots seeds a two row fixture and
selects the second row. Its rows deliberately have different completion
ratios, so that inverting the percentage calculation is caught rather
than cancelling out against a half finished download.
2026-09-04 00:39:33 -06:00
Dark-Alex-17 f73959b73f feat(readarr): add the blocklist UI
Render the Readarr blocklist table with the item details popup, the
remove item prompt and the clear blocklist prompt, and dispatch it from
ReadarrUi so the tab is reachable.

The snapshot suite covers every block in BLOCKLIST_BLOCKS plus loading
and empty states. A second set of snapshots seeds a two row fixture with
distinct values in every column and selects the second row, so that
reading items[0] instead of the current selection cannot pass.
2026-09-04 00:34:46 -06:00
Dark-Alex-17 bad43d1383 feat(readarr): add the history handler 2026-09-04 00:22:04 -06:00
Dark-Alex-17 adf0896b7d feat(readarr): add the downloads handler 2026-09-04 00:07:33 -06:00
Dark-Alex-17 bd96c4ab23 feat(readarr): add the blocklist handler 2026-09-03 23:54:49 -06:00
Dark-Alex-17 e9d3851db8 fix(readarr): stop a long author overview hiding the other fields
The overview was rendered second of twelve into a fixed height block
with no scrolling, so free text of any length pushed status, genres,
rating, path and the statistics off the bottom where they could not be
reached. Every author in a real library has an overview and the median
runs several lines, so those fields were usually invisible.

Rendering it last keeps the structured fields on screen and lets the
overview take whatever space is left.
2026-09-03 22:05:11 -06:00
Dark-Alex-17 773719dbd2 feat(readarr): add the delete book UI 2026-09-03 21:58:35 -06:00
Dark-Alex-17 2b07ad96f1 feat(readarr): add the delete author UI 2026-09-03 21:51:21 -06:00
Dark-Alex-17 7fa091cb44 feat(readarr): add the edit author UI
Renders the edit author prompt with a snapshot per step of the
selection state machine, seeding distinct quality and metadata profile
values so a swap between the two dropdowns cannot go unnoticed.
2026-09-03 21:38:37 -06:00
Dark-Alex-17 7d1ff86c68 feat(readarr): add the add author UI
Renders the author search box, the search results table, and the
multi-step add confirmation prompt with its five dropdown popups.

The prompt's per-field highlighting is conveyed purely by style, which
render_to_string_with_app discards, so the highlight mapping is extracted
into add_author_prompt_highlights and unit tested per selection step.

Enriches the fully populated author search fixture with a second result
that is absent from the library, so both branches of the in-library
marker render in one snapshot and every table column carries a value.
2026-09-03 18:03:12 -06:00
Dark-Alex-17 7500e2da24 feat(readarr): add the delete book handler
Handles the delete book prompt opened from the author details view,
toggling whether to delete files on disk and whether to add a list
exclusion before confirming.

Backing out of the prompt resets both toggles, so preferences from an
abandoned delete cannot leak into the next one.
2026-09-03 17:29:31 -06:00
Dark-Alex-17 6a7f9fe0da feat(readarr): add the delete author handler
Handles the delete author prompt, toggling whether to delete files on
disk and whether to add a list exclusion before confirming.

Backing out of the prompt resets both toggles, so preferences from an
abandoned delete cannot leak into the next one.
2026-09-03 17:18:48 -06:00
Dark-Alex-17 dc4b11cf1b feat(readarr): add the edit author handler
Handles the multi-step edit prompt covering monitored, monitor new
items, quality profile, metadata profile, path, tags, and confirmation.

The prompt can be opened from either the author list or the author
details view, so the originating block is threaded through to the
sub-block pushes and Esc returns to the view the user came from.
2026-09-03 17:07:53 -06:00
Dark-Alex-17 ab9645dee9 feat(readarr): add the add author handler
Handles the add author search input, search results, and the multi-step
add prompt covering root folder, monitor, monitor new items, quality
profile, metadata profile, tags, and confirmation.

Selecting a search result whose foreign author id already exists in the
library routes to the already-in-library popup instead of the add
prompt, so a duplicate add cannot be submitted.
2026-09-03 16:52:39 -06:00
Dark-Alex-17 da6c9e7b66 fix(ui): render detail popup labels in bold
Span::style assigns rather than patches, so chaining it after bold()
discarded the modifier that bold() had just set and every detail popup
label rendered plain. Apply the modifier to the style itself instead.

Snapshots compare rendered text and cannot observe styling, so this is
pinned by asserting the label span's modifier directly.
2026-09-03 16:31:12 -06:00
Dark-Alex-17 a311e2b895 fix(readarr): stop appending stray colons to the edition overview
Every line of the edition details popup was parsed as a label and a value,
so a line carrying no colon had one appended to it. A multi-line overview
therefore rendered with a spurious colon at the end of each continuation
line, and Goodreads sourced descriptions, which use CRLF, turned their
blank lines into a lone colon.

Split each line on the first colon only, leave a line without one
unlabelled, and trim the trailing carriage return so a CRLF blank line
renders as a real paragraph break.

Building the line is now a named function so its branches can be asserted
directly. Rendered text discards the carriage return, so a snapshot alone
cannot tell the trimming apart from leaving it in place.

A continuation line that happens to contain a colon is still styled as a
label. Telling those apart needs the details to be built as styled lines
rather than flattened to text and parsed back.
2026-09-03 13:20:21 -06:00
Dark-Alex-17 61a18cb222 fix(readarr): move the book file delete to the File tab
Pressing Del on the Editions tab opened a delete prompt operating on the
currently selected book file, which is not visible from that tab. On the
File tab, where the book files are listed, Del did nothing even though the
tab advertised a "delete book file" hint.

Gate the delete on the File tab instead and drop the now inaccurate hint
from the Editions tab clue set.

A Readarr edition carries no file id, unlike a Lidarr track, so the book
files live in their own table and the delete cannot be driven from the
editions table.
2026-09-03 13:11:51 -06:00
Dark-Alex-17 280e691a94 feat(readarr): render the edition details popup
Draw the edition details popup over the book details view. The popup body is
the scrollable text the book details handler builds from the already loaded
edition row, so it needs no tabs and no network call.

The text styling is extracted into a named function so both branches are unit
tested, and the shared test fixture now holds the real field lines instead of a
placeholder, which exercises the scroll offset and the values that themselves
contain a colon.
2026-09-03 12:18:56 -06:00
Dark-Alex-17 b4c6994362 feat(readarr): render the book details popup
Draw the book details popup over the author details view, with the editions
table, book history, the book file info tab, and the manual search releases
table, plus the automatic search, delete book file, download release, and
history details prompts.

Edition row styling is extracted into a named function so each branch is unit
tested; text snapshots discard colour and cannot see it. The shared test
fixture now populates the edition format, ISBN13, ASIN, and publisher, and the
book file media info, so the rendered columns are exercised rather than blank.
2026-09-03 12:12:03 -06:00
Dark-Alex-17 d97c20d5ac feat(readarr): reach the book file info tab from the book details tabs
The book details handler already accepted the book file info block, but the
tab state declared only three tabs, so left and right could never reach it.
Add the File tab between History and Manual Search, backed by a new context
clue static covering refresh, delete book file, and esc.
2026-09-03 12:00:40 -06:00
Dark-Alex-17 751ff6ecf5 feat(readarr): scroll and dismiss the edition details popup
Add EditionDetailsHandler, the single block popup opened from the editions
table. It owns the arrow, Home and End scrolling over its ScrollableText body,
and the Esc that pops the popup and clears the edition details modal.

The handler is nested under BookDetailsHandler, which claims the block ahead
of its own tables. It never sees the keypress that opens it, so it performs no
build, and it dispatches no network events: the popup is assembled from an
already loaded row.

Scrolling is implemented rather than stubbed. The body is mostly the edition
overview, which routinely overflows the popup, so empty scroll methods would
leave the arrow keys dead on the longest text in the view.
2026-09-03 11:51:20 -06:00
Dark-Alex-17 14deed6b81 feat(readarr): handle the book details tabs and open the edition details popup
Add BookDetailsHandler, covering the editions, history and manual search
tables, the automatic search, manual download and delete book file prompts,
and tab movement across the book details modal.

Selecting an edition builds the EditionDetailsModal client side from the
already loaded row and pushes the popup block. Readarr has no edition detail
endpoint, so there is no network request and no event to dispatch.

The delete book file prompt is guarded on the book files table being
populated. Unlike Lidarr, where a track row carries its own file id, an
edition carries none and the file id comes from a separate table that can be
empty while editions are present.
2026-09-03 11:43:49 -06:00
Dark-Alex-17 baf74d95e1 fix(readarr): render the update all authors confirmation prompt 2026-09-03 01:49:28 -06:00
Dark-Alex-17 5480514b12 fix(readarr): drop the always-empty author type column from the authors table 2026-09-03 01:46:44 -06:00
Dark-Alex-17 a8d13ae5c8 feat(readarr): render the author details view
Add AuthorDetailsUi: the author description pane plus the three author info
tabs (Books, History, and Author Search), along with the history item details,
automatic search, update and scan, and manual search confirmation popups.
LibraryUi now accepts and draws it over the authors table.

The description pane omits the Disambiguation and Type lines that the Lidarr
equivalent renders. GET /author returns neither key, so both lines would be
blank on every record. The books table drops the Type and Duration columns for
the same reason, since books have no type and no duration, and renders Files as
the book file count over the total book count.

Land create_history_event_details together with this first caller so it is not
committed as an unused function.

Extract decorate_book_row_with_style rather than inlining the row styling in
the table closure, matching decorate_author_row_with_style in the sibling
library view. Rendering to a string discards colour, so the styling branches
are only reachable by tests through a named function.
2026-09-03 01:41:01 -06:00
Dark-Alex-17 d1d5033710 test(readarr): snapshot the Readarr context row, loading, and empty states
The stats, downloads, and logo panes of the Readarr context row had no test
exercising them. Add snapshots for the populated, loading, and empty variants
of the context row, plus loading and empty variants of the tab view.
2026-09-03 01:05:43 -06:00
Dark-Alex-17 fb465b06f8 feat(readarr): handle key events for the author details view 2026-09-03 00:55:36 -06:00
Dark-Alex-17 06e2d7841e feat(readarr): handle key events for the authors library table
Add LibraryHandler, which owns the authors table and its sort, search,
and filter overlays through TableHandlingConfig, along with the add,
edit, toggle monitoring, update all, and refresh key actions and the
delete prompt. Dispatch into it from ReadarrHandler and add the main tab
left and right movement helper.

authors_sorting_options offers eight sorts: name, status, quality
profile, metadata profile, books, size, monitored, and tags. It omits
the type and tracks sorts that the artists table has. GET /author never
returns authorType, so that column would be empty on every row, and
author statistics carry no track count.

Also cover the Readarr arm of handle_events, which was previously
unreachable from any test: neutering the arm entirely left the suite
green.
2026-09-02 16:57:47 -06:00
Dark-Alex-17 d6deb6eea2 feat(readarr): fetch data on tick for the active block
Give dispatch_by_readarr_block a real match so each view requests what it
needs, and refresh the shared metadata on first render and on every poll
tick. Until now the Readarr tab rendered but never fetched anything.

Guard the conditional arms the way the other servarrs do: the manual
author search only fetches when no releases are cached, book history only
when the books table is populated, and the manual book search only when
the book details modal exists with no cached releases.

Add the selection id helpers the arms read from, named to avoid colliding
with the equivalents the other servarrs already define on App.

Book edition details gets no arm on purpose. There is no endpoint to fetch
a single edition, and the popup is fully populated before it opens.
2026-09-02 16:32:06 -06:00
Dark-Alex-17 dbd84d929d feat(readarr): edit the shared indexer settings and expose the command on the CLI 2026-09-02 15:37:25 -06:00
Dark-Alex-17 0867ae918e feat(readarr): fetch the shared indexer settings and expose them on the CLI
The fetched settings are only written into app state when nothing is
already held there, so a refresh landing while the user has the settings
prompt open cannot overwrite edits in progress.

The fixtures give every field a distinct value. Settings whose fields
all share one value cannot detect two of them being mapped to each
other's keys.
2026-09-02 13:52:26 -06:00
Dark-Alex-17 cd5ef8e093 feat(readarr): test all indexers and expose the command on the CLI
Results come back keyed only by indexer id, so each one is joined
against the cached indexer list to recover a display name, and its
validation failures are flattened into a single line for the results
table.

The endpoint reports failures with a non-2xx status, so the request
ignores the status code and parses the body either way. When the
request itself fails the results table is replaced with an empty one
rather than being left showing the previous run.
2026-09-02 13:46:36 -06:00
Dark-Alex-17 f4fd8516a6 feat(readarr): test an indexer and expose the command on the CLI
Fetch the indexer's full details, then post them back verbatim to the
test endpoint. The payload round-trips through serde_json::Value so the
fields Indexer does not model, such as infoLink and the per-field label
and type metadata, survive the round trip intact.

A successful test returns an empty JSON object and clears
indexer_test_errors; a failure returns an array whose first entry
carries the error message. The test endpoint reports failures with a
non-2xx status, so the request ignores the status code and parses the
body either way.
2026-09-02 13:39:29 -06:00
Dark-Alex-17 aea7fd2607 feat(readarr): delete an indexer and expose the command on the CLI 2026-09-01 17:16:23 -06:00
Dark-Alex-17 daf991d1eb feat(readarr): edit an indexer and expose the command on the CLI 2026-09-01 17:11:32 -06:00
Dark-Alex-17 be5826f2c4 feat(readarr): fetch all indexers and expose them on the CLI 2026-09-01 16:59:57 -06:00
Dark-Alex-17 c94b2a40d0 feat(readarr): clear the blocklist and expose the command on the CLI 2026-09-01 16:44:07 -06:00
Dark-Alex-17 fb91c2edd6 feat(readarr): delete an item from the blocklist and expose the command on the CLI 2026-09-01 16:38:09 -06:00
Dark-Alex-17 5c593b3770 feat(readarr): fetch the blocklist and expose it on the CLI 2026-09-01 16:32:13 -06:00
Dark-Alex-17 5a730f2f05 feat(readarr): manually download a release and expose the command on the CLI 2026-08-28 14:22:57 -06:00
Dark-Alex-17 999a22a37f feat(readarr): fetch releases for an author and expose them on the CLI 2026-08-28 14:15:28 -06:00
Dark-Alex-17 666084a30f feat(readarr): fetch releases for a book and expose them on the CLI 2026-08-28 14:07:15 -06:00
Dark-Alex-17 1edceba056 feat(readarr): mark a history item as failed and expose the command on the CLI 2026-08-28 13:46:39 -06:00
Dark-Alex-17 40be5714e9 feat(readarr): fetch Readarr history and expose it on the CLI 2026-08-28 13:40:40 -06:00
Dark-Alex-17 f5bcb7d83a feat(readarr): refresh Readarr downloads and expose the command on the CLI 2026-08-28 13:23:26 -06:00
Dark-Alex-17 5c9fead725 feat(readarr): delete a download and expose the command on the CLI 2026-08-28 13:18:53 -06:00
Dark-Alex-17 6139b48d90 feat(readarr): fetch Readarr downloads and expose them on the CLI 2026-08-28 13:14:07 -06:00
Dark-Alex-17 3569bcc036 feat(readarr): delete a book file and expose the command on the CLI 2026-08-28 12:58:41 -06:00
Dark-Alex-17 fbfd64eb2a feat(readarr): delete a book and expose the command on the CLI
The delete flags travel as query parameters rather than a request body,
and Readarr spells the exclusion key addImportListExclusion, not the
addImportExclusion that Radarr uses.
2026-08-27 16:19:49 -06:00
Dark-Alex-17 e30d4dbd0c feat(readarr): toggle book monitoring and expose the command on the CLI
The PUT body round-trips through serde_json::Value rather than the typed
Book, so the fields Readarr returns but Book does not declare survive the
toggle instead of being silently dropped. BOOK_JSON now carries a few of
those undeclared fields so a regression to a typed round-trip fails the
tests rather than passing unnoticed.
2026-08-27 16:12:50 -06:00
Dark-Alex-17 f2787560d6 feat(readarr): trigger an automatic book search and expose the command on the CLI 2026-08-27 15:52:44 -06:00
Dark-Alex-17 b48ce5db2e feat(readarr): fetch Readarr book history and expose it on the CLI 2026-08-27 15:40:08 -06:00
Dark-Alex-17 634d1fc1b1 feat(readarr): fetch Readarr book files and expose them on the CLI 2026-08-27 15:22:50 -06:00
Dark-Alex-17 4c6a9ece4e feat(readarr): fetch Readarr book editions and expose them on the CLI 2026-08-27 14:58:47 -06:00
Dark-Alex-17 817ebeaf90 feat(readarr): fetch Readarr book details and expose them on the CLI 2026-08-27 14:51:40 -06:00
Dark-Alex-17 41830cc072 feat(readarr): fetch Readarr books and expose them on the CLI 2026-08-27 14:38:51 -06:00
Dark-Alex-17 8752a85db5 feat(readarr): fetch Readarr author history and expose it on the CLI
Add the GetAuthorHistory event, backed by GET /history/author?authorId=N,
which returns a flat array of history records for a single author. The
fetcher mirrors the artist-history equivalent, sorting by id and carrying
the sort-prompt route guard so a refresh landing while the sort prompt is
open cannot clobber an in-progress sort selection.

Expose it as `readarr list author-history --author-id N`.
2026-08-27 11:11:24 -06:00
Dark-Alex-17 9572b68f97 feat(readarr): refresh all Readarr authors and expose the command on the CLI
Posts the RefreshAuthor command to the command endpoint with no author ID, which is how Readarr distinguishes a library wide refresh from a single author refresh. The response is returned verbatim and no app state is mutated.
2026-08-27 10:50:53 -06:00
Dark-Alex-17 c82006862e feat(readarr): refresh and rescan a Readarr author and expose the command on the CLI
Posts the RefreshAuthor command to the command endpoint with the author ID, queueing a metadata refresh and a disk scan for that author. The response is returned verbatim and no app state is mutated.

Adds the refresh subcommand group to the Readarr CLI.
2026-08-27 10:43:37 -06:00
Dark-Alex-17 e9b9aa3a14 feat(readarr): trigger automatic author searches and expose the command on the CLI
Posts the AuthorSearch command to the command endpoint with the author ID, queueing an indexer search for that author. The response is returned verbatim and no app state is mutated.

Introduces ReadarrCommandBody, the request body shared by the author and book command calls. Both ID fields are omitted from the payload when absent rather than sent as null.
2026-08-27 10:35:04 -06:00
Dark-Alex-17 f896b65fd9 feat(readarr): toggle Readarr author monitoring and expose the command on the CLI 2026-08-27 10:09:04 -06:00
Dark-Alex-17 ac8a11233f feat(readarr): delete Readarr authors and expose the command on the CLI 2026-08-27 09:54:38 -06:00
Dark-Alex-17 fcfc29894e feat(readarr): edit Readarr authors and expose the command on the CLI 2026-08-27 09:43:07 -06:00
Dark-Alex-17 e514e9b6cc feat(readarr): add authors to Readarr and expose the command on the CLI 2026-08-26 17:17:41 -06:00
Dark-Alex-17 9238c1b9d2 feat(readarr): search for new Readarr authors and expose the command on the CLI 2026-08-26 16:57:26 -06:00
Dark-Alex-17 5042dbdb3a feat(readarr): fetch Readarr author details and expose them on the CLI 2026-08-26 14:35:46 -06:00
Dark-Alex-17 817fe4d135 test(readarr): add shared network test factories 2026-08-26 14:23:15 -06:00
Dark-Alex-17 84eba9beac feat(readarr): fetch Readarr authors and expose them on the CLI
Add ReadarrEvent::ListAuthors, mapping to GET /author, and wire it up as
`managarr readarr list authors`.

This is the first Readarr event that touches the library rather than
configuration, so it creates the library/authors module tree mirroring
Lidarr's library/artists layout.

list_authors mirrors Lidarr's list_artists, including the sort-prompt
route guard: a response landing while the user has the sort prompt open
must not clobber their in-progress sort selection. Both directions of
that guard are covered by tests.
2026-08-26 11:41:42 -06:00
Dark-Alex-17 e1be1df68a feat(readarr): delete Readarr root folders and expose the command on the CLI 2026-08-26 10:38:58 -06:00
Dark-Alex-17 3475660eb2 feat(readarr): add Readarr root folders and expose the command on the CLI 2026-08-26 10:31:40 -06:00
Dark-Alex-17 e8e3376327 feat(readarr): delete Readarr tags and expose the command on the CLI 2026-08-26 10:21:55 -06:00
Dark-Alex-17 b9fa3b36c0 feat(readarr): add Readarr tags and expose the command on the CLI 2026-08-20 22:47:41 -06:00
Dark-Alex-17 29186ea03c feat(readarr): fetch Readarr root folders and expose them on the CLI
Adds the GetRootFolders event backed by GET /rootfolder, storing the result
on the Readarr root folders table, and surfaces it as
"managarr readarr list root-folders".

Root folders reuse the shared RootFolder model. Readarr omits the
unmappedFolders key that the other servarrs return, and the field is
optional, so it deserializes to None without changing the shared type.

Only the read path is included here. Adding and deleting root folders
mutate the library layout, so they are left for separate work.
2026-08-20 22:32:16 -06:00
Dark-Alex-17 ce8c51b02f test(readarr): seed system app data so empty and failure assertions can fail
The system network tests asserted that queued events, disk space, logs,
version, tasks, and updates were empty against a default state that was
already empty. Those assertions passed even with the fetcher body removed,
so they reported coverage that did not exist.

Seeding a stale value first makes the empty response test prove the fetcher
replaces rather than merges, and makes the failure tests prove the update
closure never runs on a non-success response by pinning the exact seeded
value.

The assertion on the deserialized payload in the empty response test is
left alone: it reads the response body rather than app state, so it was
never vacuous.
2026-08-20 22:24:57 -06:00
Dark-Alex-17 0cc49089b6 test(readarr): seed profile and tag maps so empty and failure assertions can fail
The empty-response and failure tests asserted the maps were empty against a
default state that was already empty, so they passed even with the fetcher
body removed. Seeding a stale entry first makes the empty-response tests prove
the fetcher replaces rather than merges, and the failure tests prove the update
closure never runs on a non-success response.
2026-08-20 22:16:38 -06:00
Dark-Alex-17 1d8ccd7ff6 feat(readarr): fetch Readarr tags and expose them on the CLI 2026-08-20 22:06:23 -06:00
Dark-Alex-17 de41d6fef7 feat(readarr): fetch Readarr metadata profiles and expose them on the CLI 2026-08-20 22:04:38 -06:00
Dark-Alex-17 4a7cf922e8 feat(readarr): fetch Readarr quality profiles and expose them on the CLI 2026-08-20 22:02:34 -06:00
Dark-Alex-17 44d147ce37 feat(readarr): fetch Readarr queued events and expose them on the CLI
An empty array is a valid response when nothing is queued, so the empty case is covered by its own test.
2026-08-20 21:52:18 -06:00
Dark-Alex-17 ab3992c372 feat(readarr): trigger Readarr tasks and expose the command on the CLI
The task name is sent as the shared CommandBody, matching the Radarr, Sonarr, and Lidarr implementations. The response is returned verbatim and no app state is mutated.
2026-08-20 21:49:28 -06:00
Dark-Alex-17 bd0274411c feat(readarr): fetch Readarr tasks and expose them on the CLI
Adds ReadarrEvent::GetTasks backed by get_readarr_tasks, populating the
Readarr tasks table, and surfaces it as 'readarr list tasks'.
2026-08-20 21:36:37 -06:00
Dark-Alex-17 690a17e152 feat(readarr): fetch Readarr logs and expose them on the CLI
Adds ReadarrEvent::GetLogs backed by get_readarr_logs, which formats each
record into the Readarr logs list, and surfaces it as
'readarr list logs --events N'.

The --output-in-log-format branch reads the Readarr log lines, and is
covered by a test that pins that source so it cannot silently drift to
another servarr's data.
2026-08-20 21:33:57 -06:00
Dark-Alex-17 c6d729162a feat(readarr): fetch Readarr updates and expose them on the CLI
Adds ReadarrEvent::GetUpdates backed by get_readarr_updates, rendering the
update history into the Readarr updates view, and surfaces it as
'readarr list updates'.

Covers the case where an update record omits the changes key, which the
live instance returns for its most recent release.
2026-08-20 21:22:31 -06:00
Dark-Alex-17 e0e82e654e fix: tolerate update records that omit the changes list
The changes field was required, so a single record without it failed the
whole response and emptied the updates list. A live instance returns five
update records of which one has no changes key, so this is reachable rather
than theoretical.

This affects every servarr, since Update is shared.
2026-08-20 21:06:28 -06:00
Dark-Alex-17 97d67a6938 feat(readarr): fetch the Readarr security config and expose it on the CLI 2026-08-20 16:15:34 -06:00
Dark-Alex-17 1072585903 feat(readarr): fetch the Readarr host config and expose it on the CLI 2026-08-20 16:07:33 -06:00
Dark-Alex-17 520b422dcc feat(readarr): fetch Readarr disk space and expose it on the CLI
Add ReadarrEvent::GetDiskSpace, mapped to GET /diskspace, and
get_readarr_diskspace, which stores the result on ReadarrData for the
root folder and system views.

Surface it as `managarr readarr list disk-space`, matching the other
three servarrs.

Readarr returns a label field on some records that DiskSpace does not
model; serde drops it, so the CLI prints only the path and the free and
total space, as it does for the other three servarrs.
2026-08-20 15:49:56 -06:00
Dark-Alex-17 d8f171ec6f feat(readarr): fetch the Readarr system status and expose it on the CLI
Add ReadarrEvent::GetStatus, mapped to GET /system/status, and
get_readarr_status in the new readarr_network/system module. It stores
the version and start time on ReadarrData so the system view and the
tick refresh can read them.

Surface it as `managarr readarr get system-status`, matching the
Radarr, Sonarr, and Lidarr spelling of the same call. A `list status`
spelling would have made the same command a different verb on the
fourth servarr, which is a difference the user would have to remember
for no benefit.

Readarr instances answer /system/status with thirty keys; SystemStatus
models the two that are used and serde ignores the rest, as it does for
the other three servarrs.

This is the first ReadarrCommand variant, so ReadarrCliHandler now
reads its app and network fields.
2026-08-20 15:47:40 -06:00
Dark-Alex-17 cd341c3d71 feat(readarr): add the Readarr health check network call
Inhabit ReadarrEvent with its first variant, HealthCheck, mapped to
GET /health, and add get_readarr_healthcheck alongside the equivalent
Lidarr, Radarr, and Sonarr fetchers. The call is internal only and has
no CLI surface, matching the other three servarrs; it exists so that an
unreachable instance surfaces an error banner on tick.

The healthcheck fetcher lives in readarr_network/mod.rs rather than a
submodule, so its tests live in readarr_network_tests.rs next to the
resource() and NetworkEvent conversion tests, mirroring Lidarr.

MockServarrApi::build_for matched NetworkEvent by value with no Radarr
fallback arm. It compiled only while ReadarrEvent was uninhabited and
no value could reach it; the first variant makes the match non
exhaustive, so it now assigns the config to the Readarr server tab.
2026-08-20 15:44:07 -06:00
Dark-Alex-17 29f043f5c7 feat: scaffold Readarr support across the state, app, handler, UI, and CLI layers
Promote Route::Readarr from a unit stub to a tuple variant and wire Readarr
through every layer, mirroring the Lidarr tree. Readarr appears as a fourth
servarr tab and its library view renders the Authors table.

Adds ReadarrData and its modals, the tick and context clue modules, the
top-level ReadarrHandler, ReadarrUi with the Authors library view, and an
empty ReadarrCommand behind ReadarrCliHandler. The default port is 8787.

Promoting the route variant compiles cleanly without any of the arms that
give it behaviour, so all six of those sites are wired explicitly here:
App::on_tick, handlers::handle_events, handle_prompt_toggle,
ServarrContextClueProvider, ui(), and verify_config_present_for_cli. Each was
checked by removing the arm and confirming its covering assertion then fails.

ReadarrEvent and ReadarrCommand are both declared empty. They gain variants
alongside the network calls and CLI commands that use them, which keeps each
later change to a single function.

Adding the tab changes the rendered tab bar, so 13 existing snapshots
regenerate. The only delta in each is that single tab bar line.

This scaffold adds 24 dead_code warnings, all inside the Readarr tree and
none outside it. They clear as the handlers, UI, network events, and CLI
commands that consume these items land: block arrays awaiting their accepts
implementations and BlockSelectionState seeding, modal and state fields
awaiting their handlers, the CLI handler fields awaiting an inhabited command
enum, and the top-level handler fields awaiting sub-handlers. The count must
strictly decrease from here, so a later increase is a defect rather than
transient noise. The 12 pre-existing collapsible_match warnings are unrelated
and untouched.
2026-08-20 15:28:49 -06:00
Dark-Alex-17 4db9b92146 refactor: move shared servarr types into servarr_models
SystemStatus was declared three times, byte for byte identically, in
radarr_models, sonarr_models, and lidarr_models. MetadataProfile and the
Lidarr release download body were each declared in a single servarr module
despite having nothing servarr specific about them: MetadataProfile is an
id and name pair structurally identical to QualityProfile, which already
lives in servarr_models, and a download body of a guid plus an indexer id
is the generic form of that request.

Move all three into servarr_models, preserving that file's alphabetical
ordering, and drop the prefix from LidarrReleaseDownloadBody now that it is
no longer owned by one servarr.

RadarrReleaseDownloadBody and SonarrReleaseDownloadBody deliberately stay
where they are. Radarr's carries a movie id and Sonarr's carries optional
series, episode, and season fields, so the three types are not
interchangeable and only the Lidarr shape is the common one.

Behavior is unchanged. Every remaining edit is an import path update or the
rename at a call site.
2026-08-20 10:52:05 -06:00
Dark-Alex-17 a362add8f0 feat: updated gitignore to not include coyote memory or workspace config 2026-08-19 13:03:14 -06:00
Alex Clarke e779f70187 Merge pull request #63 from JMcCumberIO/fix/tui-idle-cpu-and-error-banners
Check / stable / fmt (push) Failing after 30s
Check / beta / clippy (push) Failing after 1m17s
Check / stable / clippy (push) Failing after 1m21s
Check / nightly / doc (push) Successful in 1m7s
Check / 1.95.0 / check (push) Successful in 1m9s
Test Suite / ubuntu / beta (push) Successful in 1m51s
Test Suite / ubuntu / stable (push) Successful in 1m52s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m27s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
fix: reduce TUI idle CPU and improve server error banners
2026-07-06 08:58:06 -06:00
Jonathan McCumberandClaude Sonnet 4.6 a9054d9a98 chore: cargo fmt --all
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 09:18:50 -05:00
Jonathan McCumberandClaude Sonnet 4.6 645ca19d76 fix: address reviewer feedback — rename scroll field to bool, relocate static, drop env var
- Rename `ui_scroll_tick_count: u64` → `should_text_scroll: bool` across all 33
  call sites; `on_ui_scroll_tick` now assigns true/false instead of 0/1
- Remove two now-redundant comments from `test_on_ui_scroll_tick`
- Move `WHITESPACE_RE` static declaration below module imports in network/mod.rs
- Remove `MANAGARR_TICK_RATE_MS` env var and its parsing helpers; `recv()`-based
  blocking already brings idle CPU to near-0, and tuning tick rate silently
  stretches the poll cadence — keep `DEFAULT_TICK_RATE_MS` const only

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3FpbniZme9mJqgFkMVowz
2026-07-04 20:30:20 -05:00
Jonathan McCumberandClaude Sonnet 4.6 6f697f64f4 fix: revert draw gating; replace frame-count scroll with wall-clock timer
- Revert src/main.rs to draw-at-top-of-loop (original structure) so
  auto-scrolling is no longer coupled to event rate. Lock is released
  before blocking on recv() so the network task is never starved.

- Replace ticks_until_scroll counter in on_ui_scroll_tick() with an
  Instant + Duration (default 100ms). Scroll now advances at a fixed
  wall-clock rate (10 chars/sec) regardless of draw rate or tick rate,
  which is the correct long-term fix for the animation regression.

- Update tests: assert scroll_interval == 100ms in default/new tests;
  rewrite test_on_ui_scroll_tick to cover zero-interval (always fires)
  and long-interval (suppressed) cases.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C3FpbniZme9mJqgFkMVowz
2026-06-30 20:05:00 -05:00
Jonathan McCumber a72b2991fd fix: address reviewer feedback — regex OnceLock and restore tick rate
- Compile whitespace regex once via OnceLock instead of on every
  non-success response; makes format_error_body infallible (String
  return, no Result) eliminating the only practical failure mode
- Revert default tick rate from 100ms back to 50ms to preserve the
  existing 20-second Servarr poll cadence (tick_until_poll=400 × 50ms);
  the recv() change is the real CPU fix — the tick rate change was
  unnecessary and silently halved the data refresh rate
- Keep MANAGARR_TICK_RATE_MS as opt-in override; note that tuning it
  proportionally stretches the poll interval alongside the tick rate
2026-06-28 13:00:16 -05:00
Jonathan McCumber 6c41e0704e fix: reduce TUI idle CPU and improve server error banners
- Replace non-blocking try_recv() event loop with blocking recv() to
  eliminate idle spin; the sender tick timeout still drives minimum
  wakeup rate so the UI stays responsive
- Lower default tick rate from 50 ms to 100 ms (10 ticks/sec vs 20);
  add MANAGARR_TICK_RATE_MS env var for runtime tuning
- Move terminal draws to event boundaries (key + tick) instead of every
  loop pass, reducing unnecessary redraws
- In network error handling, prefer concise JSON fields (message /
  errorMessage / error) over the raw HTTP body, so Radarr ASP.NET
  stack traces don't flood the TUI banner; full body still logged
- Add unit tests for tick-rate parsing and Radarr-style JSON error body

Observed: ~0.6% total CPU at idle vs measurably higher with prod defaults
2026-06-28 12:35:37 -05:00
Dark-Alex-17 6e7707f6df build: upgraded MSRV in the Dockerfile 2026-06-25 15:41:56 -06:00
github-actions[bot] a26444006b chore: bump Cargo.toml to 0.7.3
Check / stable / fmt (push) Failing after 27s
Check / beta / clippy (push) Failing after 1m13s
Check / stable / clippy (push) Failing after 1m15s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.95.0 / check (push) Successful in 1m9s
Test Suite / ubuntu / beta (push) Successful in 1m47s
Test Suite / ubuntu / stable (push) Successful in 1m47s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m24s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-06-25 20:58:11 +00:00
github-actions[bot] ccf3e28323 bump: version 0.7.2 → 0.7.3 [skip ci] 2026-06-25 20:58:10 +00:00
Alex Clarke 6eea6b92fb Merge pull request #62 from Dark-Alex-17/develop
Security fixes, rolling logs, and Rust version upgrade
2026-06-25 14:37:16 -06:00
Dark-Alex-17 6df68b8a66 tests: Addressed additional CR comments and added tests for tail-logs 2026-06-25 14:21:57 -06:00
Dark-Alex-17 cbca6bd916 fix: addressed code review comments 2026-06-25 14:11:18 -06:00
Dark-Alex-17 92187c5f16 fix: tail-logs subcommand follows log rollovers and sleeps to minimize idle CPU loops 2026-06-25 13:51:32 -06:00
Dark-Alex-17 366809d8c6 fmt: applied formatting 2026-06-25 13:32:31 -06:00
Dark-Alex-17 dd93fe117d feat: Implemented log rolling so the log file doesn't just grow exponentially [#60] 2026-06-25 13:29:56 -06:00
Dark-Alex-17 10e18af1bf build: upgraded to openssl 0.10.79 to fix security vulnerabilities 2026-06-25 13:20:14 -06:00
Dark-Alex-17 a4d93692a9 build: Upgraded to Rust v1.95.0 2026-06-25 13:19:27 -06:00
Dark-Alex-17 03d134bec9 refactor: Refactored several usages of sort_by_key and match guards to utilize newer Rust version APIs 2026-06-25 13:18:43 -06:00
Dark-Alex-17 4cad9e1755 docs: Improved the README to demonstrate what a truly minimal configuration looks like for each service and explain the "reasonable defaults" [#61] 2026-06-25 12:35:51 -06:00
github-actions[bot] adb76bb603 bump: version 0.7.1 → 0.7.2 [skip ci] 2026-04-20 22:37:15 +00:00
Alex Clarke d9a2b1c6c4 Merge pull request #59 from Dark-Alex-17/develop
Check / stable / fmt (push) Failing after 25s
Check / beta / clippy (push) Failing after 1m12s
Check / stable / clippy (push) Failing after 1m11s
Check / nightly / doc (push) Successful in 1m3s
Check / 1.89.0 / check (push) Successful in 1m6s
Test Suite / ubuntu / beta (push) Successful in 1m46s
Test Suite / ubuntu / stable (push) Successful in 1m47s
Test Suite / ubuntu / stable / coverage (push) Failing after 2m19s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
merge develop into main for release
2026-04-20 16:18:55 -06:00
Dark-Alex-17 47a2fbf0cc docs: removed the lines-of-code counter from the README since the link is dead 2026-04-20 16:02:18 -06:00
Dark-Alex-17 7067902752 build: updated reqwest and build dependencies 2026-04-20 15:53:42 -06:00
Dark-Alex-17 ba1cf0182b build: Updated the docker image so that it ships with trusted CA root certs from trusted providers like LetsEncrypt, DigiCert, etc. for docker SSL users 2026-04-02 09:58:30 -06:00
Dark-Alex-17 5cccec88c9 docs: Updated the README to have a more detailed section on how to acquire SSL certificate information 2026-03-30 10:13:51 -06:00
Dark-Alex-17 bbcd3f00a9 feat: Created a separate 'ssl' property for the config so users don't have to specify an ssl_cert_path to use SSL or use the uri workaround for HTTPS API access 2026-03-29 12:39:26 -06:00
Dark-Alex-17 2e339dd73b docs: Created an authorship policy and PR template that requires explicit acknowledgement of AI assistance 2026-02-24 17:41:34 -07:00
Dark-Alex-17 f988cf0f26 docs: Fixed some typos found in the README
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 1m2s
Check / 1.89.0 / check (push) Successful in 1m9s
Test Suite / ubuntu / beta (push) Successful in 1m47s
Test Suite / ubuntu / stable (push) Successful in 1m43s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m52s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-05 18:50:42 -07:00
Dark-Alex-17 ff82dc2012 style: Upgraded rustfmt edition to 2024
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 57s
Check / 1.89.0 / check (push) Successful in 1m1s
Test Suite / ubuntu / beta (push) Successful in 1m43s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m52s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-05 10:47:35 -07:00
github-actions[bot] 89a692ad90 chore: bump Cargo.toml to 0.7.1
Check / stable / fmt (push) Successful in 9m55s
Check / beta / clippy (push) Successful in 10m59s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 59s
Check / 1.89.0 / check (push) Successful in 1m2s
Test Suite / ubuntu / beta (push) Successful in 1m42s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m51s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-02-04 18:01:02 +00:00
github-actions[bot] d77ec5fb34 bump: version 0.7.0 → 0.7.1 [skip ci] 2026-02-04 18:01:00 +00:00
Alex Clarke ec90e2dca7 Merge pull request #56 from Dark-Alex-17/develop
Check / stable / fmt (push) Successful in 9m53s
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
Hotfix and feature adds for some new issues
2026-02-04 10:38:58 -07:00
Dark-Alex-17 5a4e6c9623 style: Addressed CR comments about adding an explicit Command::ConfigPath to the main command match statement 2026-02-04 10:24:59 -07:00
Dark-Alex-17 9a6a06ee20 build: Updated all dependencies to resolve dependabot security issues 2026-02-04 09:51:57 -07:00
Dark-Alex-17 5556e48fc0 fix: Improved the system notification feature so it can persist between modals 2026-02-04 08:18:04 -07:00
Dark-Alex-17 af573cac2a feat: Added support for a system-wide notification popup mechanism that works across Servarrs 2026-02-03 17:03:12 -07:00
Dark-Alex-17 447cf6a2b4 style: Applied formatting to the artist_details_ui 2026-02-03 08:10:02 -07:00
Dark-Alex-17 203bf9cb66 fix: Sonarr API updated to somtimes allow either seeders or leechers to be null 2026-02-03 08:00:31 -07:00
Dark-Alex-17 4f9bc34d23 docs: Updated the README so that the example configuration only includes references to Servarrs that are actually supported [#55] 2026-01-30 15:45:22 -07:00
Dark-Alex-17 a2aa9507a9 fix: Improved the first-time run behavior so that it outputs the default configuration file it tries to load to help users locate the file on first-runs 2026-01-30 15:36:26 -07:00
Dark-Alex-17 c791b985f0 docs: Updated README to tell users to use 'managarr config-path' as the default method to discover the location of the default configuration file 2026-01-29 12:56:59 -07:00
Dark-Alex-17 5c517a748c fix: 'managarr config-path' should work without a pre-existing config already in place [#54] 2026-01-29 12:54:45 -07:00
Dark-Alex-17 892c687077 docs: Updated README with config-path as another way to find the default config file for a given system 2026-01-29 10:26:12 -07:00
Dark-Alex-17 c6d5b98e86 feat: Implemented a 'config-path' command to print out the default Managarr configuration file path to help address #54 2026-01-29 10:23:05 -07:00
Dark-Alex-17 67e5114ec2 build: Removed #[allow(dead_code)] from the LIDARR_LOGO since it is now being utilized 2026-01-26 11:56:05 -07:00
Dark-Alex-17 fdc331865e feat: Full support for filtering disks and aggregating root folders in the UI's 'Stats' block 2026-01-26 11:10:59 -07:00
Dark-Alex-17 f388dccc08 feat: proper collapsing of root folder paths in the stats layer of the UI 2026-01-22 14:44:48 -07:00
Dark-Alex-17 64fad3b9bc refactor: Removed the filtering of monitored_storage_paths from the networking module and migrated all of it to the UI 2026-01-22 13:12:51 -07:00
Dark-Alex-17 3be7b09da8 feat: Added config option to filter for specific disk space paths to display in the UI (CLI is unaffected) 2026-01-22 10:49:30 -07:00
Dark-Alex-17 5f3123cd79 test: Updated snapshot tests to assert the paths are updated in the UI
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 10m59s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 59s
Check / 1.89.0 / check (push) Successful in 1m7s
Test Suite / ubuntu / beta (push) Successful in 1m48s
Test Suite / ubuntu / stable (push) Successful in 1m43s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m55s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-01-22 09:39:44 -07:00
Dark-Alex-17 d8f7febfe1 feat: Improved disk-space UI and CLI that shows the actual path being monitored instead of just a disk number
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-01-22 09:36:58 -07:00
Dark-Alex-17 0bfbb44e3e feat: Implemented the forgotten lidarr list disk-space command
Check / stable / fmt (push) Successful in 9m59s
Check / beta / clippy (push) Successful in 10m58s
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2026-01-22 09:06:38 -07:00
github-actions[bot] c5161f828d chore: bump Cargo.toml to 0.7.0
Check / stable / fmt (push) Successful in 9m57s
Check / beta / clippy (push) Successful in 11m0s
Check / stable / clippy (push) Successful in 10m59s
Check / nightly / doc (push) Successful in 57s
Check / 1.89.0 / check (push) Successful in 1m0s
Test Suite / ubuntu / beta (push) Successful in 1m42s
Test Suite / ubuntu / stable (push) Successful in 1m42s
Test Suite / ubuntu / stable / coverage (push) Successful in 12m38s
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
2026-01-21 19:22:11 +00:00
github-actions[bot] 71c64167f0 bump: version 0.6.3 → 0.7.0 [skip ci] 2026-01-21 19:22:03 +00:00
Alex Clarke 4d3e00fd94 Merge pull request #52 from Dark-Alex-17/lidarr
Lidarr Support
2026-01-21 11:57:50 -07:00
Dark-Alex-17 9e96e74c87 ci: Fixed the docker-build Justfile recipe
Check / stable / fmt (pull_request) Successful in 10m40s
Check / beta / clippy (pull_request) Successful in 10m59s
Check / stable / clippy (pull_request) Successful in 10m59s
Check / nightly / doc (pull_request) Successful in 58s
Check / 1.89.0 / check (pull_request) Successful in 1m2s
Test Suite / ubuntu / beta (pull_request) Successful in 1m43s
Test Suite / ubuntu / stable (pull_request) Successful in 1m41s
Test Suite / ubuntu / stable / coverage (pull_request) Successful in 13m1s
Test Suite / macos-latest / stable (pull_request) Has been cancelled
Test Suite / windows-latest / stable (pull_request) Has been cancelled
2026-01-21 10:39:51 -07:00
Dark-Alex-17 ddb869c341 docs: Reword some Sonarr manual search CLI docs to be more explicit about how the results are filtered 2026-01-20 14:37:42 -07:00
Dark-Alex-17 f17f542e8e refactor: Refactored the SonarrEvent enum to not unnecessarily wrap dual series_id and season_number values in a tuple when both values can be passed directly 2026-01-19 16:44:10 -07:00
Dark-Alex-17 a2e6400a38 docs: Updated README with information about Lidarr support 2026-01-19 16:29:02 -07:00
Dark-Alex-17 89f5ff6bc7 feat: Blocklist support in Lidarr in both the CLI and TUI 2026-01-19 16:13:11 -07:00
Dark-Alex-17 eff1a901eb feat: CLI and TUI support for track history and track details in Lidarr 2026-01-19 14:50:20 -07:00
Dark-Alex-17 7add62b245 fix: Sonarr network wasn't checking for the user to be using the sorting block when populating season details 2026-01-19 14:50:01 -07:00
Dark-Alex-17 5fa9b08347 fix: Sonarr CLI was not properly filtering out episode and season releases when manually searching for releases 2026-01-19 14:49:17 -07:00
Dark-Alex-17 7bb5f83a56 feat: Lidarr UI support for album details popup 2026-01-16 23:21:25 -07:00
Dark-Alex-17 caf4ad1e64 feat: Implemented TUI handler support for the Album Details popup in Lidarr 2026-01-16 17:16:44 -07:00
Dark-Alex-17 bc6ecc39f4 feat: Bulk added CLI support for tracks and album functionalities in Lidarr 2026-01-16 14:38:08 -07:00
Dark-Alex-17 5e70d70758 test: Wrote tests for the Lidarr manual artist search key handler 2026-01-16 11:00:38 -07:00
Dark-Alex-17 1329589bd6 feat: Implemented the manual artist discography search tab in Lidarr's artist details UI 2026-01-15 14:36:09 -07:00
Dark-Alex-17 c6dc8f6090 feat: Lidarr CLI support for downloading a release 2026-01-15 12:57:54 -07:00
Dark-Alex-17 0ee275d58f fix: Sonarr manual search TUI and CLI incorrectly displaying the same unfiltered results for both season and episode searches 2026-01-15 12:43:16 -07:00
Dark-Alex-17 8dfa664a06 feat: CLI support for searching for discography releases in Lidarr 2026-01-15 11:39:34 -07:00
Dark-Alex-17 d7f0dd5950 feat: Added TUI and CLI support for viewing Artist history in Lidarr 2026-01-14 16:09:37 -07:00
Dark-Alex-17 8b9467bd39 feat: Full Lidarr system support for both the CLI and TUI 2026-01-14 14:50:33 -07:00
Dark-Alex-17 c74d5936d2 feat: Full CLI and TUI support for the Lidarr Indexers tab 2026-01-14 13:30:51 -07:00
Dark-Alex-17 8abcf44866 feat: Full support for adding a root folder in Lidarr from both the CLI and TUI 2026-01-14 09:06:27 -07:00
Dark-Alex-17 d2217509f2 feat: naive lidarr root folder tab implementation. Needs improved add logic 2026-01-13 14:33:12 -07:00
Dark-Alex-17 c68cd75015 feat: Downloads tab support in Lidarr 2026-01-13 13:40:18 -07:00
Dark-Alex-17 e1a25bfaf2 refactor: Improved and simplified the implementation of history details for both Sonarr and Lidarr 2026-01-13 12:36:21 -07:00
Dark-Alex-17 ad9e2b3671 feat: Created a History tab in the Radarr UI and created a list history command and mark-history-item-as-failed command for Radarr 2026-01-13 12:35:54 -07:00
Dark-Alex-17 0172253d20 fix: Slowed down the automatic text scrolling in tables so the text is readable 2026-01-12 15:28:41 -07:00
Dark-Alex-17 47fdee190a fix: Expanded the history item details size so that it can include all the available information for a given item; was previously being cut off on some screens 2026-01-12 15:28:03 -07:00
Dark-Alex-17 68b08d1cd7 feat: Implemented the Lidarr History tab and CLI support 2026-01-12 14:43:05 -07:00
Dark-Alex-17 f31810e48a feat: TUI support for deleting a Lidarr album from the artist details popup 2026-01-09 17:21:10 -07:00
Dark-Alex-17 09bee7473f feat: CLI support for deleting an album from Lidarr 2026-01-09 16:33:32 -07:00
Dark-Alex-17 b2814371f0 feat: Completed support for viewing Lidarr artist details 2026-01-09 16:22:03 -07:00
Dark-Alex-17 269057867f fix: Bug in submitting the update series prompt in the series details UI in Sonarr 2026-01-09 14:01:46 -07:00
Dark-Alex-17 450fdd7106 test: Added tests for the Lidarr context clues for the add artist popup 2026-01-08 15:20:17 -07:00
Dark-Alex-17 c624d1b9e4 feat: Full CLI and TUI support for adding an artist to Lidarr 2026-01-08 15:16:01 -07:00
Dark-Alex-17 e94f78dc7b refactor: Let serde serialize Add Series and Add Movie enums instead of calling to_string up front 2026-01-08 10:52:30 -07:00
Dark-Alex-17 b1a6db21f1 fix: Don't include Lidarr artist disambiguation in Edit popup title when it is empty 2026-01-08 10:09:15 -07:00
Dark-Alex-17 ca208ff5e4 fix: Refactored how quality profiles, language profiles, and metadata profiles are populated for each servarr so they sort using the ID to mimic the web UI better 2026-01-08 10:05:15 -07:00
Dark-Alex-17 1a43d1ec7c feat: Include the Lidarr artist disambiguation in the title of the Edit Artist popup 2026-01-08 09:28:56 -07:00
Dark-Alex-17 4abf705cb5 fix: Added the correct keybinding context to the Lidarr edit artist popup 2026-01-08 09:28:12 -07:00
Dark-Alex-17 cf98b10d77 style: Modified the LineGauge style so its more readable in the UI 2026-01-07 17:19:32 -07:00
Dark-Alex-17 f0ed71b436 build: Upgraded to Ratatui v0.30.0 and fixed a new security vulnerability [#13] 2026-01-07 17:15:54 -07:00
Dark-Alex-17 243de47cae feat: Initial Lidarr support for searching for new artists 2026-01-07 15:53:18 -07:00
Dark-Alex-17 d3947d9e15 fix: Improved fault tolerance for search result tables and test all indexer results tables 2026-01-07 14:58:32 -07:00
Dark-Alex-17 64d8c65831 fix: Prevented additional empty slice errors in indexer tables 2026-01-07 14:09:12 -07:00
Dark-Alex-17 60c4cf1098 fix: Fixed a bug in all Servarr implementations to not try to get the current selection of a search table when an error is returned from the API 2026-01-07 14:00:02 -07:00
Dark-Alex-17 9cc3ccb419 feat: Lidarr CLI commands to list quality profiles and metadata profiles 2026-01-07 13:15:50 -07:00
Dark-Alex-17 45c61369c8 feat: Improved CLI readability by creating a separate Global Options section for global flags 2026-01-07 13:08:23 -07:00
Dark-Alex-17 a8609e08c5 feat: CLI support for deleting a tag in Lidarr 2026-01-07 12:50:23 -07:00
Dark-Alex-17 a18b047f4f feat: Lidarr CLI support for listing and adding tags 2026-01-07 12:20:39 -07:00
Dark-Alex-17 b1afdaf541 feat: Added CLI and TUI support for editing Lidarr artists 2026-01-07 12:01:03 -07:00
Dark-Alex-17 3c1634d1e3 testing 2026-01-07 10:45:49 -07:00
Dark-Alex-17 9b4eda6a9d feat: Support for updating all Lidarr artists in both the CLI and TUI 2026-01-06 12:47:10 -07:00
Dark-Alex-17 96308afeee feat: Added Lidarr CLI support for fetching the host config and the security config 2026-01-06 11:00:19 -07:00
Dark-Alex-17 4e13d5d34d style: Applied formatting for the lidarr_command_tests 2026-01-06 10:25:05 -07:00
Dark-Alex-17 b4a99d1665 feat: Created Lidarr commands: 'get artist-details' and 'get system-status' 2026-01-06 10:24:51 -07:00
Dark-Alex-17 a012f6ecd5 feat: Fetch the artist members as part of the artist details query 2026-01-06 10:10:28 -07:00
Dark-Alex-17 5afee1998b feat: Support for toggling the monitoring of a given artist via the CLI and TUI 2026-01-06 09:40:16 -07:00
Dark-Alex-17 059fa48bd9 style: Applied uniform formatting across all new Lidarr files 2026-01-05 15:46:16 -07:00
Dark-Alex-17 6771a0ab38 feat: Full support for deleting an artist via CLI and TUI 2026-01-05 15:44:51 -07:00
Dark-Alex-17 bc3aeefa6e feat: TUI support for Lidarr library 2026-01-05 13:10:30 -07:00
Dark-Alex-17 e61537942b test: Implemented tests for the Lidarr list artists command 2026-01-05 11:28:35 -07:00
Dark-Alex-17 5d09b2402c feat: CLI support for listing artists 2026-01-05 10:58:48 -07:00
Dark-Alex-17 368f7505ff feat: Improved UI speed and responsiveness
Check / stable / fmt (push) Has been cancelled
Check / beta / clippy (push) Has been cancelled
Check / stable / clippy (push) Has been cancelled
Check / nightly / doc (push) Has been cancelled
Check / 1.89.0 / check (push) Has been cancelled
Test Suite / ubuntu / beta (push) Has been cancelled
Test Suite / ubuntu / stable (push) Has been cancelled
Test Suite / macos-latest / stable (push) Has been cancelled
Test Suite / windows-latest / stable (push) Has been cancelled
Test Suite / ubuntu / stable / coverage (push) Has been cancelled
2025-12-19 13:41:14 -07:00
Dark-Alex-17 6a9fd0999c build: Added additional compile-time optimizations for speed improvements and a smaller binary 2025-12-17 16:45:51 -07:00
Alex Clarke d8ac94d067 Merge pull request #51 from Dark-Alex-17/test-refactoring
Refactored all tests and wrote snapshot tests to finally cover the UI module
2025-12-16 16:41:04 -07:00
Dark-Alex-17 0532d59746 test: Wrote snapshot tests for all Sonarr UI 2025-12-16 14:12:10 -07:00
Dark-Alex-17 e0fcbc71e1 ci: updated Cargo Husky hooks to not use the Makefile 2025-12-15 20:21:14 -07:00
Dark-Alex-17 c072c57bbb style: Applied uniform formatting across all new UI test files 2025-12-15 20:18:28 -07:00
Dark-Alex-17 aadd6c8abf fix: Fixed an issue with the Managarr table that would incorrectly try to display things before is_loading was ready 2025-12-15 20:17:28 -07:00
Dark-Alex-17 316ed64315 test: Created snapshot tests for all Radarr UI modules 2025-12-15 20:17:09 -07:00
Dark-Alex-17 7084ca1be2 ci: Added more aliases and commands to the Justfile for snapshot tests 2025-12-15 20:16:10 -07:00
Dark-Alex-17 317daddb8e fix: Fixed a bug where the edit collection popup would not display when opening it from collection details 2025-12-15 20:15:52 -07:00
Dark-Alex-17 8ef291efd8 ci: Fixed some typos in the justfile 2025-12-15 13:22:00 -07:00
Dark-Alex-17 92be9c50bf docs: Updated contributing docs to mention how to use Just 2025-12-15 13:18:54 -07:00
Dark-Alex-17 f0e5ecd5de ci: Migrated to using Just instead of using Makefiles and included a helix config 2025-12-15 13:16:16 -07:00
Dark-Alex-17 e2c44583e8 style: Applied formatting to recently merged in files 2025-12-15 08:37:54 -07:00
Dark-Alex-17 5da741f3a9 ci: Updated Cargo.lock after main merge 2025-12-15 08:37:28 -07:00
Dark-Alex-17 35c5eb65cb Merge remote-tracking branch 'origin/main' into test-refactoring
# Conflicts:
#	Cargo.lock
#	src/cli/mod.rs
#	src/cli/radarr/add_command_handler.rs
#	src/cli/radarr/get_command_handler.rs
#	src/cli/radarr/list_command_handler.rs
2025-12-15 07:55:14 -07:00
Dark-Alex-17 7e53a26e5f Merge remote-tracking branch 'origin/main' into test-refactoring 2025-12-15 07:52:47 -07:00
github-actions[bot] 436b3f85d0 bump: version 0.6.2 → 0.6.3 [skip ci] 2025-12-13 20:23:15 +00:00
Dark-Alex-17 9c1a9cc3c5 fix: Wrapped all Sonarr use of Language with Option to fix the 'null' array issue in the new Sonarr API 2025-12-13 13:06:33 -07:00
Dark-Alex-17 82f30f126d test: Implemented UI snapshot tests 2025-12-12 15:44:11 -07:00
github-actions[bot] 9599ac28ca chore: bump Cargo.toml to 0.6.2 2025-12-12 17:02:43 +00:00
github-actions[bot] e71a699ed8 bump: version 0.6.1 → 0.6.2 [skip ci] 2025-12-12 17:02:30 +00:00
Dark-Alex-17 ff4eb8ca98 ci: Specify commitizen version [skip ci] 2025-12-12 10:00:11 -07:00
Dark-Alex-17 b69973b9af ci: Fix the commitizen version issue [skip ci] 2025-12-12 09:54:15 -07:00
Dark-Alex-17 3e133fa147 refactor: Replaced all modulo usages of tick_until_poll with is_multiple_of 2025-12-12 09:20:05 -07:00
Dark-Alex-17 ae506789ab fix: Fixed breaking Sonarr Episode file API calls after recent Sonarr API update 2025-12-12 09:17:36 -07:00
Dark-Alex-17 c3fa689617 refactor: Use is_multiple_of for the tick counter in the UI module 2025-12-09 14:30:17 -07:00
Dark-Alex-17 b51e42b4b2 refactor: Updated all model tests to use purpose-built assertions to improve readability and maintainability 2025-12-09 14:29:35 -07:00
Dark-Alex-17 d4bea91186 refactor: Updated all handler tests to use purpose built assertions to improve readability and maintainability 2025-12-09 14:28:47 -07:00
Dark-Alex-17 d47dadeb88 refactor: Used is_multiple_of to make life easier and cleaner in the app module 2025-12-09 14:28:09 -07:00
Dark-Alex-17 b807904c6c refactor: Refactored all cli tests to use purpose-built assertions 2025-12-08 17:07:31 -07:00
Dark-Alex-17 ee1bee22eb refactor: Improved test assertions in the app module 2025-12-08 16:47:40 -07:00
Dark-Alex-17 f6c4c1623f style: Removed unused imports after massive handler testing refactor 2025-12-04 23:03:48 -07:00
Dark-Alex-17 49fd086b92 refactor: Created dedicated proptests and assertions to clean up the handler unit tests 2025-12-04 22:57:06 -07:00
Dark-Alex-17 35dce0bf01 refactor: Migrated the handle_table_events macro into a trait for better IDE support, created a TableEventAdapter wrapper for the KeyEventHandlers to make it so that the trait can be used properly and a simple function to replace the previous call to the handle_table_events macro 2025-12-04 16:03:58 -07:00
Dark-Alex-17 71240373c0 refactor: Simplified both the table_handler macro and the stateful_table implementation 2025-12-04 11:34:45 -07:00
Dark-Alex-17 659023d561 refactor: Improved error handling for the tail-logs subcommand to propagate errors up the stack instead of exiting there. 2025-12-04 10:10:19 -07:00
Dark-Alex-17 a0073b65ad refactor: Added accessor methods to servarr_data structs, replaced for loops with functional iterator chains, eliminated mutable state tracking, and updated network module to use get_or_insert_default() for modal options 2025-12-04 10:02:32 -07:00
Dark-Alex-17 cba53e0841 refactor: Improved error handling project-wide and cleaned up some regexes with unnecessary escapes (tail_logs and interpolate_env_vars) 2025-12-04 09:03:58 -07:00
Dark-Alex-17 e50fb88bfc refactor: Refactored to use more idiomatic let-else statements where applicable 2025-12-03 15:23:07 -07:00
Dark-Alex-17 ad58912baf test: Updated Rust edition to 2024 and refactored network module tests to be more idiomatic 2025-12-03 14:49:27 -07:00
Dark-Alex-17 c4e8d64710 ci: Fixed changelog generation for releases 2025-09-14 18:46:28 -06:00
Dark-Alex-17 ca4319001c docs: Updated the Homebrew tap description [skip-ci] 2025-09-12 11:25:20 -06:00
Dark-Alex-17 ebc58b831d ci: Modified the CI/CD pipeline to bump the procedural macros as well 2025-09-02 17:27:12 -06:00
github-actions[bot] b1572c903c chore: bump Cargo.toml to 0.6.1 2025-09-02 22:58:28 +00:00
github-actions[bot] 5b73924e2a bump: version 0.6.0 → 0.6.1 [skip ci] 2025-09-02 22:58:25 +00:00
Dark-Alex-17 97ce258a8d fix: Fixed UI bugs introduced as part of the hotkey refactor 2025-09-02 15:49:36 -06:00
Dark-Alex-17 fedec79a95 refactor: Updated crate to publish properly with the procedural macros 2025-08-29 17:31:14 -06:00
Dark-Alex-17 8e74709b9c chore(release): Add metadata to enum_display_style_derive 2025-08-29 17:22:15 -06:00
github-actions[bot] be09e1d932 bump: version 0.5.1 → 0.6.0 [skip ci] 2025-08-29 22:33:29 +00:00
Alex Clarke d95417d075 Merge pull request #49 from Dark-Alex-17/develop
chore: Pre-release merge develop into main
2025-08-29 16:14:22 -06:00
Dark-Alex-17 2e3e511e3b ci: Improved the release workflow to be more compatible with Act local testing 2025-08-29 15:41:39 -06:00
Dark-Alex-17 8555052cc4 build: Modified the Dockerfile to be able to build the procedural macros as well 2025-08-29 15:09:22 -06:00
Dark-Alex-17 80bc6793c7 ci: Updated the release flow to also update the Cargo.toml version automatically 2025-08-29 13:45:14 -06:00
Dark-Alex-17 049a0c5d49 docs: Updated CONTRIBUTING.md to explain how to use act 2025-08-29 13:27:36 -06:00
Dark-Alex-17 ae9cb77e6d ci: Added step to release flow to update the Cargo.toml version 2025-08-29 11:13:32 -06:00
Dark-Alex-17 126ed5ed72 build: Updated dependencies 2025-08-29 10:59:29 -06:00
Dark-Alex-17 63ae64cebd ci: Modified CI/CD workflows so they can be tested locally with Act 2025-08-29 10:58:39 -06:00
Dark-Alex-17 0b29351366 feat: Support for custom headers to be added to every request to each server to support alternative authentication mechanisms [#47] 2025-08-29 10:23:17 -06:00
Dark-Alex-17 4ea39f74fe Updated the Rust version to 1.89 in the dockerfile to match the crate definition 2025-08-26 16:43:15 -06:00
Dark-Alex-17 76fcf5e67f docs: Fixed a typo in the README 2025-08-15 14:39:47 -06:00
Dark-Alex-17 11457736e6 refactor: Network module is now broken out into similar directory structures for each servarr to mimic the rest of the project to make it easier to develop and maintain 2025-08-14 13:14:23 -06:00
Dark-Alex-17 e2a6af1cbd refactor: Refactored the IndexerTestResut model into the general Servarr models 2025-08-12 17:05:17 -06:00
Dark-Alex-17 20ea15009d build: Updated dependencies and upgraded to Rust 1.89.0 2025-08-12 16:56:45 -06:00
Dark-Alex-17 00ab0f27f7 feat: Refactor all keybinding tips into a dynamically changing menu that can be invoked via '?' [#32] 2025-08-12 16:27:34 -06:00
Dark-Alex-17 1f4870d082 feat: Display total disk usage for series in the Library view to mirror Radarr functionality [#44] 2025-08-11 10:24:00 -06:00
Dark-Alex-17 e96af7410e feat: Pagination support for jumping 20 items at a time in all table views [#45] 2025-08-08 17:04:28 -06:00
Dark-Alex-17 345bb8ce03 refactor: Renamed 'ctrl-*' keyboard shortcuts to 'C-*' to simplify and shrink the on-screen help 2025-08-08 15:09:39 -06:00
Dark-Alex-17 dbcfc77ad4 feat: Support toggling Movie monitoring directly from the library view [#43] 2025-08-08 14:54:28 -06:00
Dark-Alex-17 e653532212 feat: Support toggling Movie monitoring from the CLI 2025-08-08 14:49:15 -06:00
Dark-Alex-17 8e7e31f64d feat: Support toggling Series monitoring directly from the Sonarr library view [#43] 2025-08-08 14:48:27 -06:00
Dark-Alex-17 bff3795cc6 feat: Support toggling Series monitoring from the CLI 2025-08-08 14:46:35 -06:00
Dark-Alex-17 8782f1353d docs: Formatted and cleaned up README a bit 2025-08-08 14:10:33 -06:00
Dark-Alex-17 f08f255a46 fix: Marked Radarr studios as nullable to prevent crashes 2025-08-07 20:05:05 -06:00
Dark-Alex-17 02870043ec style: Addressed updated linter complaints 2025-08-07 19:49:11 -06:00
Dark-Alex-17 154e491922 fix: Fixed a bug where the Sonarr API was returning empty values for seeders when searching for season releases 2025-08-07 16:00:10 -06:00
Dark-Alex-17 ef5e702255 feat: Fixed the Radarr downloads tab to display more than 10 downloads at a time and added a new --count flag to the CLI for specifying the number of downloads to return 2025-07-13 15:04:39 -06:00
Dark-Alex-17 cb4cd93bcd feat: Fetch more than 10 downloads when listing Sonarr downloads, and add a --count flag to the CLI to specify how many downloads to fetch 2025-07-13 14:48:15 -06:00
Alex Clarke ee034c9caf Update README.md to remove the cheeky "try before you buy" heading since some users reported it as misleading 2025-05-29 23:48:16 -06:00
Dark-Alex-17 c133a4ecd2 fix: Improve fault tolerance for tag associations in Radarr and Sonarr 2025-05-18 13:49:52 -06:00
Dark-Alex-17 f09a2efa5e Updated README to have labels for the theme examples for better readability 2025-04-08 17:31:29 -06:00
Dark-Alex-17 49983f4173 fix: Upgraded to the most recent version of Tokio to mitigate CWE-664 2025-04-08 11:05:32 -06:00
Dark-Alex-17 0f9894e1be fix: Updated all dependencies and updated openssl to the most recent version to mitigate CWE-416 2025-04-07 15:22:56 -06:00
Alex Clarke 8c0dffec31 Merge pull request #41 from cwelsys/fix-key-event-handling
fix: Ensure key events are only processed on key press to avoid dupli…
2025-04-07 12:01:14 -06:00
Alex Clarke 0085d944ea Merge branch 'develop' into fix-key-event-handling 2025-04-07 11:58:06 -06:00
Connor Welsh 89cf0e66a6 fix: Ensure key events are only processed on key press to avoid duplicates 2025-04-06 17:29:06 -04:00
Alex Clarke baef436f78 Merge pull request #39 from Dark-Alex-17/alternative-keymappings
Alternative keymappings
2025-04-01 10:11:22 -06:00
Dark-Alex-17 cf00d7992e fix: Updated the name of the should_ignore_quit_key to ignore_special_keys_for_textbox_input to give a better idea of what the flag is used for; also added alt keybinding for backspace 2025-03-27 15:21:44 -06:00
Dark-Alex-17 f25829f3c1 Merge remote-tracking branch 'refs/remotes/origin/develop' 2025-03-21 11:56:12 -06:00
Dark-Alex-17 8c783bc405 fix: Marked videoCodecs as Option to resolve #38 2025-03-21 11:55:54 -06:00
Dark-Alex-17 0048d71b74 feat: Support alternative keymappings for all keys, featuring hjkl movements 2025-03-17 22:02:15 -06:00
Dark-Alex-17 c633347ecc Merge remote-tracking branch 'refs/remotes/origin/develop' 2025-03-17 20:49:52 -06:00
Alex Clarke ecd6a0ec32 Merge pull request #37 from Dark-Alex-17/custom-themes
Support Themes
2025-03-17 14:23:18 -06:00
Dark-Alex-17 30507d9d01 docs: Updated the README to include the new flags 2025-03-17 13:26:46 -06:00
Dark-Alex-17 6245a794d5 docs: Update all screenshots to not have any auto-generated usernames in the tags columns 2025-03-10 16:22:24 -06:00
Dark-Alex-17 5c822e4890 Merge branch 'develop' 2025-03-10 16:13:48 -06:00
Dark-Alex-17 cab06fe43f fix: Marked the Season.statistics field as Option so that a panic does not happen for outdated Sonarr data. This resolves #35 2025-03-10 16:13:04 -06:00
Dark-Alex-17 b4ff5f3351 feat: Added the Eldritch theme and updated documentation 2025-03-10 15:49:40 -06:00
Dark-Alex-17 0834802481 fix: When adding a film from the Collection Details modal, the render order was wrong: Radarr Library -> Collection Table -> Add Movie Prompt (missing the Collection details prompt too). Correct order is: Collection Table -> Collection Details Modal -> Add Movie Modal 2025-03-10 15:08:02 -06:00
Dark-Alex-17 3afd74dcbf fix: Fixed a bug that was rendering encompassing blocks after other widgets were rendered, thus overwriting the custom styles on each previously rendered widget 2025-03-10 15:01:58 -06:00
Dark-Alex-17 b1a0bdfbb6 Merge branch 'develop' 2025-03-07 12:02:47 -07:00
Dark-Alex-17 6d38bc5e1d Merge branch 'main' 2025-03-07 12:02:19 -07:00
Dark-Alex-17 5ba1ba15c9 ci: Update to the most recent Rust version 2025-03-07 11:55:32 -07:00
Dark-Alex-17 db05d2abfb Merge branch 'develop' into custom-themes 2025-03-07 10:37:48 -07:00
Dark-Alex-17 1840c4e39a Merge branch 'main' into develop
# Conflicts:
#	proc_macros/enum_display_style_derive/src/lib.rs
2025-03-07 10:37:23 -07:00
Dark-Alex-17 c5a3f424d6 refactor: Reformatted code to make the format checks pass 2025-03-07 10:36:40 -07:00
Dark-Alex-17 04aa6b81b5 Merge branch 'develop' into custom-themes 2025-03-07 10:35:07 -07:00
Dark-Alex-17 5ff3b9b996 Merge branch 'main' into develop 2025-03-07 10:34:16 -07:00
Dark-Alex-17 228e4a61a4 fix: Updated ring dependency to mitigate CWE-770 2025-03-07 10:33:57 -07:00
Dark-Alex-17 df38ea5413 feat: Write built in themes to the themes file on first run so users can define custom themes 2025-03-06 17:44:52 -07:00
Dark-Alex-17 709f6ca6ca test: Added integration tests for the ValidateTheme macro 2025-03-06 16:00:50 -07:00
Dark-Alex-17 b012fc29e4 Merge branch 'develop' into custom-themes
# Conflicts:
#	Cargo.toml
2025-03-06 15:35:05 -07:00
Dark-Alex-17 bdad723aef refactor: Formatted files using rustfmt 2025-03-06 15:32:59 -07:00
Dark-Alex-17 f97d46cec3 refactor: Created a derive macro for defining the display style of Enum models and removed the use of the EnumDisplayStyle trait 2025-03-06 15:29:30 -07:00
Dark-Alex-17 7381eaef57 refactor: Expanded the serde_enum_from macro to further reduce code duplication 2025-03-05 15:09:51 -07:00
Dark-Alex-17 72c922b311 feat: Created a theme validation macro to verify theme configurations before allowing the TUI to start 2025-03-05 14:37:34 -07:00
Alex Clarke fd14a8152c fix: change the name of the theme configuration file to 'themes' 2025-03-04 18:29:21 -07:00
Dark-Alex-17 5cb60c317d feat: Initial support for custom user-defined themes 2025-03-04 18:09:09 -07:00
Dark-Alex-17 847de75713 fix: Modified the Sonarr DownloadRecord so that the episode_id is optional to prevent crashes for weird downloads 2025-03-01 14:50:20 -07:00
Dark-Alex-17 58723cf3e8 ci: Ensure the docker release is fully up-to-date 2025-02-28 21:45:05 -07:00
Dark-Alex-17 c613168bfb docs: Updated the CHANGELOG accordingly 2025-02-28 21:26:13 -07:00
github-actions[bot] 6f83de77f2 chore: Bump the version in Cargo.lock 2025-03-01 03:30:05 +00:00
github-actions[bot] 3f6ef3beb4 bump: version 0.5.0 → 0.5.1 [skip ci] 2025-03-01 03:30:04 +00:00
Dark-Alex-17 14e50c1465 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-02-28 20:29:29 -07:00
Dark-Alex-17 0aa9fdca14 ci: Overwrite previous artifact uploads for proper releases 2025-02-28 20:29:15 -07:00
github-actions[bot] dc50820abc chore: Bump the version in Cargo.lock 2025-03-01 03:08:01 +00:00
github-actions[bot] 81afce78ad bump: version 0.4.2 → 0.5.0 [skip ci] 2025-03-01 03:07:57 +00:00
Dark-Alex-17 8a0b912601 ci: Updated the release flow to use the newer upload/download artifact actions 2025-02-28 20:06:41 -07:00
Dark-Alex-17 85105a953e docs: Pre-Release update of versions and added link to the Matrix Space. 2025-02-28 16:45:16 -07:00
Dark-Alex-17 40f3452d08 ci: Removed the minimal-versions check 2025-02-27 20:51:50 -07:00
Dark-Alex-17 a287a5c903 docs: Updated the README to also include details on the new CLI flags 2025-02-27 20:51:00 -07:00
Dark-Alex-17 f30e5270d8 refactor: Updated dependencies 2025-02-27 20:45:32 -07:00
Dark-Alex-17 104bcd7bb2 refactor: Addressed Cargo fmt complaints 2025-02-27 20:42:32 -07:00
Dark-Alex-17 fd6fcfc98f feat: CLI Support for multiple Servarr instances 2025-02-27 20:37:03 -07:00
Dark-Alex-17 f87e02cd7c test: Added in unit tests for TUI support for multiple custom named Servarrs 2025-02-27 19:30:17 -07:00
Dark-Alex-17 9b63b10118 feat: Support for multiple servarr definitions - no tests [skip ci] 2025-02-27 18:00:28 -07:00
Dark-Alex-17 111485e7c4 feat: Support for loading Servarr API tokens from a file 2025-02-27 16:53:29 -07:00
Alex Clarke 0167753cfe Merge pull request #30 from tangowithfoxtrot/var-interpolation
feat: environment variable interpolation in the managarr config file
2025-02-19 17:52:23 -07:00
Alex Clarke 73131cc518 Merge branch 'main' into var-interpolation 2025-02-19 17:40:16 -07:00
Dark-Alex-17 25576757bb ci: Updated codecov config to consider patches as well to hopefully fix PR issues [skip ci] 2025-02-19 17:40:03 -07:00
Dark-Alex-17 105c8f3a82 test: Hopefully the final environment variable name fix to correct all race conditions with parallel tests 2025-02-19 17:27:56 -07:00
Dark-Alex-17 5164d81492 test: Fix a potential race condition happening with parallel tests 2025-02-19 15:59:31 -07:00
Dark-Alex-17 319e5f1ac2 test: Added remaining unit tests for the deserialize_optional_env_var deserialization functions 2025-02-19 15:44:55 -07:00
Alex Clarke b24c2fbeb1 Merge branch 'main' into var-interpolation 2025-02-19 15:14:41 -07:00
Dark-Alex-17 bc5053c39c fix: Updated openssl to 0.10.70 to mitigate CVE-2025-24898 2025-02-03 16:06:47 -07:00
tangowithfoxtrot f06a031c93 Merge branch 'main' into var-interpolation 2025-01-26 14:40:14 -08:00
tangowithfoxtrot 8d450dea5a Merge branch 'main' into var-interpolation 2025-01-26 14:36:45 -08:00
Dark-Alex-17 c4ace8c53f feat: Tweaked the implementation for environment variables in the config a bit 2025-01-26 14:59:09 -07:00
Dark-Alex-17 78f104f558 refactor: Added a debug line for logging to output the config used when starting Managarr 2025-01-26 14:56:37 -07:00
Dark-Alex-17 e8a6f740b9 refactor: Updated the 2018 idiom lint to the 2021_compatibility lint 2025-01-26 14:47:40 -07:00
tangowithfoxtrot 6f3c6ec840 feat: var interpolation 2025-01-26 09:28:47 -08:00
Alex Clarke 47a3ef1d8b Merge pull request #29 from Dark-Alex-17/license-update
Updated license and attribution requirements
2025-01-21 15:47:00 -07:00
Alex Clarke 367e9bf33b Added attribution guidelines to the CONTRIBUTING
Added attribution guidelines to the CONTRIBUTING file so the license is easier to understand
2025-01-21 15:10:01 -07:00
Alex Clarke f122b02424 Do not require attributions for forks
Update the LICENSE to not require an attribution for forks that merge back into the main/aren't distributed as separate projects
2025-01-21 14:58:35 -07:00
Alex Clarke 3a09c17f0a Update LICENSE
Made the attribution wording more flexible and less legal in nature.
2025-01-21 14:53:30 -07:00
Alex Clarke 6773abb04e Update LICENSE due to scammer abuse
Updated the LICENSE to incorporate an attribution clause, and prohibit commercial use. 

This was done in response to a recently discovered abuse of some scammers using both my GitHub and this project in job applications to scam other companies and job applicants, offering to "adapt the project to the needs of their internal tools". 

Previously, this would have been fine. However, with scams spiking recently and myself struggling to find a job, this kind of abuse is, regrettably, something I must limit.
2025-01-21 13:14:15 -07:00
Dark-Alex-17 b757d66d7a fix: Addressed rustfmt complaints 2025-01-18 15:33:56 -07:00
Dark-Alex-17 81cb7a750c refactor: Removed unnecessary clones in the networking module to speed up network request handling 2025-01-18 15:23:03 -07:00
Dark-Alex-17 3be59108a9 refactor: Corrected some clone instead of copy behaviors in the command line handlers 2025-01-18 14:54:25 -07:00
Dark-Alex-17 fac9c45aee refactor: Removed unnecessary clone from stateful table 2025-01-18 14:24:23 -07:00
Dark-Alex-17 184bd2b510 refactor: Removed unnecessary clone call from extract_and_add_tag_ids_vec method 2025-01-18 14:15:52 -07:00
Dark-Alex-17 fda69178b9 refactor: Reduced the number of clones necessary when building modal structs 2025-01-18 13:56:18 -07:00
Dark-Alex-17 652bbcd5d4 refactor: Refactored a handful of Option calls to use take instead 2025-01-18 13:00:21 -07:00
Dark-Alex-17 fd35106df8 refactor: Renamed KeyEventHandler::with to KeyEventHandler::new to keep with Rust best practices and conventions 2025-01-18 12:43:25 -07:00
Alex Clarke 5ead5bc3d6 docs: removed the Unraid section of the README now that the issue has been corrected and fixed. 2024-12-31 16:55:39 -06:00
Dark-Alex-17 3ce0003315 docs: Added installation instructions for Nix and a note for Unraid users until the template is corrected by the maintainer 2024-12-30 11:25:15 -07:00
Alex Clarke ee94059a15 fix: Corrected typo in the managarr.nuspec.template 2024-12-21 21:26:38 -07:00
Dark-Alex-17 844742053d ci: Finalized corrected release workflow [skip ci] 2024-12-21 16:52:29 -07:00
1511 changed files with 189718 additions and 26314 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
--artifact-server-path=./.act/artifacts
--cache-server-path=./.act/cache
--container-options --privileged
--env ACT=true
+2 -5
View File
@@ -7,8 +7,5 @@ echo "Running pre-push hook:"
echo "Executing: cargo fmt" echo "Executing: cargo fmt"
cargo fmt cargo fmt
echo "Executing: make lint" echo "Executing: cargo clippy --all"
make lint cargo clippy --all
echo "Executing: cargo test"
cargo test
+4 -4
View File
@@ -7,8 +7,8 @@ echo "Running pre-push hook:"
echo "Executing: cargo fmt --check" echo "Executing: cargo fmt --check"
cargo fmt --check cargo fmt --check
echo "Executing: make lint" echo "Executing: cargo clippy --all"
make lint cargo clippy --all
echo "Executing: cargo test" echo "Executing: cargo test --all"
cargo test cargo test --all
+18
View File
@@ -0,0 +1,18 @@
### Description
<!-- What problem is this PR solving? -->
### Alternatives Considered (Optional)
<!-- What alternatives did you consider? Why did you choose this approach? -->
### AI assistance (if any)
<!-- Required if AI assistance was used to write or modify code in this PR. -->
<!-- - List tools here -->
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself. If I didn't, I gave proper attribution to AI assistance above.
- [ ] I understand how it works end-to-end
- [ ] I can maintain this code in the future
- [ ] No undisclosed AI-generated code was used
- [ ] If AI assistance was used, it is documented above
+9 -9
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -46,7 +46,7 @@ jobs:
toolchain: [stable, beta] toolchain: [stable, beta]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -54,7 +54,7 @@ jobs:
components: clippy components: clippy
- name: Run clippy action - name: Run clippy action
uses: clechasseur/rs-clippy-check@v3 uses: clechasseur/rs-clippy-check@v5
- name: Cache Cargo dependencies - name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
@@ -65,7 +65,7 @@ jobs:
name: nightly / doc name: nightly / doc
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install Rust nightly - name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly uses: dtolnay/rust-toolchain@nightly
@@ -76,15 +76,15 @@ jobs:
RUSTDOCFLAGS: --cfg docsrs RUSTDOCFLAGS: --cfg docsrs
msrv: msrv:
# check that we can build using the minimal rust version that is specified by this crate # check that we can build using the minimal rust version that is specified by this crate
name: 1.82.0 / check name: 1.95.0 / check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install 1.82.0 - name: Install 1.95.0
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: 1.82.0 toolchain: 1.95.0
- name: cargo +1.82.0 check - name: cargo +1.95.0 check
run: cargo check run: cargo check
+383 -195
View File
@@ -8,9 +8,9 @@ on:
workflow_dispatch: workflow_dispatch:
inputs: inputs:
bump_type: bump_type:
description: "Specify the type of version bump" description: 'Specify the type of version bump'
required: true required: true
default: "patch" default: 'patch'
type: choice type: choice
options: options:
- patch - patch
@@ -23,6 +23,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Configure SSH for Git - name: Configure SSH for Git
if: env.ACT != 'true'
run: | run: |
mkdir -p ~/.ssh mkdir -p ~/.ssh
echo "${{ secrets.RELEASE_BOT_SSH_KEY }}" > ~/.ssh/id_ed25519 echo "${{ secrets.RELEASE_BOT_SSH_KEY }}" > ~/.ssh/id_ed25519
@@ -30,20 +31,27 @@ jobs:
ssh-keyscan -H github.com >> ~/.ssh/known_hosts ssh-keyscan -H github.com >> ~/.ssh/known_hosts
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 if: env.ACT != 'true'
uses: actions/checkout@v5
with: with:
ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }} ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }}
fetch-depth: 0 fetch-depth: 0
- name: Set up Python - name: Checkout repository
uses: actions/setup-python@v4 if: env.ACT == 'true'
uses: actions/checkout@v5
with: with:
python-version: "3.10" fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
- name: Install Commitizen - name: Install Commitizen
run: | run: |
python -m pip install --upgrade pip python3 -m pip install --upgrade pip
pip install commitizen pip3 install commitizen==4.8.3
npm install -g conventional-changelog-cli npm install -g conventional-changelog-cli
- name: Configure Git user - name: Configure Git user
@@ -62,12 +70,6 @@ jobs:
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Update the Cargo.lock
run: |
cargo update
git add Cargo.lock
git commit -m "chore: Bump the version in Cargo.lock"
- name: Get the new version tag - name: Get the new version tag
id: version id: version
run: | run: |
@@ -84,25 +86,154 @@ jobs:
echo "Previous tag: $PREV_TAG" echo "Previous tag: $PREV_TAG"
echo "prev_version=$PREV_TAG" >> $GITHUB_ENV echo "prev_version=$PREV_TAG" >> $GITHUB_ENV
- name: Bump Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml Cargo.lock || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml Cargo.lock; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml Cargo.lock
git commit -m "chore: bump Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Bump validate_theme_derive/Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}/proc_macros/validate_theme_derive
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml
git commit -m "chore: bump validate_theme_derive Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Bump enum_display_style_derive/Cargo.toml version
shell: bash
working-directory: ${{ github.workspace }}/proc_macros/enum_display_style_derive
env:
VERSION: ${{ env.version }}
run: |
set -euo pipefail
: "${VERSION:?env.version is empty}"
# Ignore Act's local artifact dir noise
echo artifacts/ >> .git/info/exclude || true
# Edit the version line right after name="managarr"
sed -E -i '
/^[[:space:]]*name[[:space:]]*=[[:space:]]*"managarr"[[:space:]]*$/ {
n
s|^[[:space:]]*version[[:space:]]*=[[:space:]]*"[^"]*"|version = "'"$VERSION"'"|
}
' Cargo.toml
cargo update || true
# Git config that helps in containers (Act)
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config --global --add safe.directory "$GITHUB_WORKSPACE"
# Debug: show what changed
git status --porcelain
git diff --name-only -- Cargo.toml || true
# Only commit if one of these files actually changed
if ! git diff --quiet -- Cargo.toml; then
# Stage only modifications of already tracked files (won't pick up artifacts/)
git add -u -- Cargo.toml
git commit -m "chore: bump enum_display_style_derive Cargo.toml to $VERSION"
else
echo "No changes to commit (already at $VERSION)"
fi
- name: Generate changelog for the version bump - name: Generate changelog for the version bump
id: changelog id: changelog
run: | run: |
changelog=$(conventional-changelog -p angular -i CHANGELOG.md -s --from ${{ env.prev_version }} --to ${{ env.version }}) conventional-changelog -p angular -i CHANGELOG.md --from ${{ env.prev_version }} --to v${{ env.version }} > artifacts/changelog.md
echo "$changelog" > artifacts/changelog.md
echo "changelog_body=$(cat artifacts/changelog.md)" >> $GITHUB_ENV
- name: Push changes - name: Push changes
if: env.ACT != 'true'
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git push origin --follow-tags git push origin --follow-tags
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v6
with: with:
name: artifacts
path: artifacts path: artifacts
- name: Upload the changed Cargo files (Act)
if: env.ACT == 'true'
uses: actions/upload-artifact@v6
with:
name: bumped-cargo-files
path: |
Cargo.toml
Cargo.lock
proc_macros/validate_theme_derive/Cargo.toml
proc_macros/enum_display_style_derive/Cargo.toml
build-release-artifacts: build-release-artifacts:
name: build-release name: build-release
needs: [bump-version] needs: [bump-version]
@@ -130,28 +261,36 @@ jobs:
steps: steps:
- name: Check if actor is repository owner - name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner }} if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: | run: |
echo "You are not authorized to run this workflow." echo "You are not authorized to run this workflow."
exit 1 exit 1
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Ensure repository is up-to-date - name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: | run: |
git fetch --all git fetch --all
git pull git pull
- uses: actions/cache@v3 - name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- uses: actions/cache@v5
name: Cache Cargo registry name: Cache Cargo registry
with: with:
path: ~/.cargo/registry path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3 - uses: actions/cache@v5
if: startsWith(matrix.job.name, 'linux-') if: startsWith(matrix.job.name, 'linux-')
with: with:
path: ~/.cargo/bin path: ~/.cargo/bin
@@ -236,28 +375,36 @@ jobs:
cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/ cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v6
with: with:
name: artifacts name: artifacts-${{ env.RELEASE_NAME }}
path: artifacts path: artifacts
overwrite: true
publish-github-release: publish-github-release:
name: publish-github-release name: publish-github-release
needs: [build-release-artifacts] needs: [build-release-artifacts]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v3 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v7
with: with:
name: artifacts
path: artifacts path: artifacts
merge-multiple: true
- name: Ensure repository is up-to-date - name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: | run: |
git fetch --all git fetch --all
git pull git pull
@@ -266,11 +413,15 @@ jobs:
run: | run: |
release_version="$(cat ./artifacts/release-version)" release_version="$(cat ./artifacts/release-version)"
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
changelog_body="$(cat ./artifacts/changelog.md)"
echo "changelog_body=$(cat artifacts/changelog.md)" >> $GITHUB_ENV - name: Validate release environment variables
run: |
echo "Release version: ${{ env.RELEASE_VERSION }}"
echo "Changelog body: $(cat artifacts/changelog.md)"
- name: Create a GitHub Release - name: Create a GitHub Release
uses: softprops/action-gh-release@v1 if: env.ACT != 'true'
uses: softprops/action-gh-release@v3
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with: with:
@@ -300,201 +451,238 @@ jobs:
artifacts/managarr-armv7-musl.tar.gz artifacts/managarr-armv7-musl.tar.gz
artifacts/managarr-armv7-musl.sha256 artifacts/managarr-armv7-musl.sha256
tag_name: v${{ env.RELEASE_VERSION }} tag_name: v${{ env.RELEASE_VERSION }}
name: "v${{ env.RELEASE_VERSION }}" name: 'v${{ env.RELEASE_VERSION }}'
body: ${{ env.changelog_body }} body_path: artifacts/changelog.md
draft: false draft: false
prerelease: false prerelease: false
- name: Upload artifacts - name: Upload artifacts
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v6
with: with:
name: artifacts
path: artifacts path: artifacts
overwrite: true
# publish-chocolatey-package: publish-chocolatey-package:
# needs: [publish-github-release] needs: [publish-github-release]
# name: Publish Chocolatey Package name: Publish Chocolatey Package
# runs-on: windows-latest runs-on: windows-latest
# steps: steps:
# - name: Checkout repository - name: Check if actor is repository owner
# uses: actions/checkout@v4 if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
# with: run: |
# fetch-depth: 1 echo "You are not authorized to run this workflow."
exit 1
# - name: Get release artifacts - name: Checkout repository
# uses: actions/download-artifact@v3 uses: actions/checkout@v5
# with: with:
# name: artifacts fetch-depth: 1
# path: artifacts
# - name: Set release assets and version - name: Get release artifacts
# shell: pwsh uses: actions/download-artifact@v7
# run: | with:
# # Read the first column from the SHA256 file path: artifacts
# $windows_sha = Get-Content ./artifacts/managarr-windows.sha256 | ForEach-Object { $_.Split(' ')[0] } merge-multiple: true
# Add-Content -Path $env:GITHUB_ENV -Value "WINDOWS_SHA=$windows_sha"
# # Read the release version from the release-version file - name: Set release assets and version
# $release_version = Get-Content ./artifacts/release-version shell: pwsh
# Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_VERSION=$release_version" run: |
# Read the first column from the SHA256 file
$windows_sha = Get-Content ./artifacts/managarr-windows.sha256 | ForEach-Object { $_.Split(' ')[0] }
Add-Content -Path $env:GITHUB_ENV -Value "WINDOWS_SHA=$windows_sha"
# - name: Validate release environment variables # Read the release version from the release-version file
# run: | $release_version = Get-Content ./artifacts/release-version
# echo "Release SHA windows: ${{ env.WINDOWS_SHA }}" Add-Content -Path $env:GITHUB_ENV -Value "RELEASE_VERSION=$release_version"
# echo "Release version: ${{ env.RELEASE_VERSION }}"
# - name: Package and Publish package to Chocolatey - name: Validate release environment variables
# run: | run: |
# mkdir ./deployment/chocolatey/tools echo "Release SHA windows: ${{ env.WINDOWS_SHA }}"
# # Run packaging script echo "Release version: ${{ env.RELEASE_VERSION }}"
# python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/managarr.nuspec.template" "./deployment/chocolatey/managarr.nuspec" ${{ env.WINDOWS_SHA }}
# python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/chocolateyinstall.ps1.template" "./deployment/chocolatey/tools/chocolateyinstall.ps1" ${{ env.WINDOWS_SHA }}
# # Publish to Chocolatey - name: Package and Publish package to Chocolatey
# cd ./deployment/chocolatey if: env.ACT != 'true'
# choco pack run: |
# echo y | choco install managarr -dv -s . mkdir ./deployment/chocolatey/tools
# $version = managarr --version # Run packaging script
# $version = $version -replace " ", "." python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/managarr.nuspec.template" "./deployment/chocolatey/managarr.nuspec" ${{ env.WINDOWS_SHA }}
# choco push $version.nupkg -s https://push.chocolatey.org/ --api-key ${{ secrets.CHOCOLATEY_API_KEY }}; python "./deployment/chocolatey/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/chocolatey/chocolateyinstall.ps1.template" "./deployment/chocolatey/tools/chocolateyinstall.ps1" ${{ env.WINDOWS_SHA }}
# publish-homebrew-formula: # Publish to Chocolatey
# needs: [publish-github-release] cd ./deployment/chocolatey
# name: Update Homebrew formulas choco pack
# runs-on: ubuntu-latest echo y | choco install managarr -dv -s .
# steps: $version = managarr --version
# - name: Checkout repository $version = $version -replace " ", "."
# uses: actions/checkout@v4 choco push $version.nupkg -s https://push.chocolatey.org/ --api-key ${{ secrets.CHOCOLATEY_API_KEY }};
# with:
# fetch-depth: 1
# - name: Get release artifacts publish-homebrew-formula:
# uses: actions/download-artifact@v3 needs: [publish-github-release]
# with: name: Update Homebrew formulas
# name: artifacts runs-on: ubuntu-latest
# path: artifacts steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
# - name: Set release assets and version - name: Checkout repository
# shell: bash uses: actions/checkout@v5
# run: | with:
# # Set environment variables fetch-depth: 1
# macos_sha="$(cat ./artifacts/managarr-macos.sha256 | awk '{print $1}')"
# echo "MACOS_SHA=$macos_sha" >> $GITHUB_ENV
# macos_sha_arm="$(cat ./artifacts/managarr-macos-arm64.sha256 | awk '{print $1}')"
# echo "MACOS_SHA_ARM=$macos_sha_arm" >> $GITHUB_ENV
# linux_sha="$(cat ./artifacts/managarr-linux-musl.sha256 | awk '{print $1}')"
# echo "LINUX_SHA=$linux_sha" >> $GITHUB_ENV
# release_version="$(cat ./artifacts/release-version)"
# echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
# - name: Validate release environment variables - name: Get release artifacts
# run: | uses: actions/download-artifact@v7
# echo "Release SHA macos: ${{ env.MACOS_SHA }}" with:
# echo "Release SHA macos-arm: ${{ env.MACOS_SHA_ARM }}" path: artifacts
# echo "Release SHA linux musl: ${{ env.LINUX_SHA }}" merge-multiple: true
# echo "Release version: ${{ env.RELEASE_VERSION }}"
# - name: Execute Homebrew packaging script - name: Set release assets and version
# run: | shell: bash
# # run packaging script run: |
# python "./deployment/homebrew/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/homebrew/managarr.rb.template" "./managarr.rb" ${{ env.MACOS_SHA }} ${{ env.MACOS_SHA_ARM }} ${{ env.LINUX_SHA }} # Set environment variables
macos_sha="$(cat ./artifacts/managarr-macos.sha256 | awk '{print $1}')"
echo "MACOS_SHA=$macos_sha" >> $GITHUB_ENV
macos_sha_arm="$(cat ./artifacts/managarr-macos-arm64.sha256 | awk '{print $1}')"
echo "MACOS_SHA_ARM=$macos_sha_arm" >> $GITHUB_ENV
linux_sha="$(cat ./artifacts/managarr-linux-musl.sha256 | awk '{print $1}')"
echo "LINUX_SHA=$linux_sha" >> $GITHUB_ENV
release_version="$(cat ./artifacts/release-version)"
echo "RELEASE_VERSION=$release_version" >> $GITHUB_ENV
# - name: Push changes to Homebrew tap - name: Validate release environment variables
# env: run: |
# TOKEN: ${{ secrets.MANAGARR_GITHUB_TOKEN }} echo "Release SHA macos: ${{ env.MACOS_SHA }}"
# run: | echo "Release SHA macos-arm: ${{ env.MACOS_SHA_ARM }}"
# # push to Git echo "Release SHA linux musl: ${{ env.LINUX_SHA }}"
# git config --global user.name "Dark-Alex-17" echo "Release version: ${{ env.RELEASE_VERSION }}"
# git config --global user.email "alex.j.tusa@gmail.com"
# git clone https://Dark-Alex-17:${{ secrets.MANAGARR_GITHUB_TOKEN }}@github.com/Dark-Alex-17/homebrew-managarr.git
# rm homebrew-managarr/Formula/managarr.rb
# cp managarr.rb homebrew-managarr/Formula
# cd homebrew-managarr
# git add .
# git diff-index --quiet HEAD || git commit -am "Update formula for Managarr release ${{ env.RELEASE_VERSION }}"
# git push https://$TOKEN@github.com/Dark-Alex-17/homebrew-managarr.git
# publish-docker-image: - name: Execute Homebrew packaging script
# needs: [publish-github-release] if: env.ACT != 'true'
# name: Publishing Docker image to Docker Hub run: |
# runs-on: ubuntu-latest # run packaging script
# steps: python "./deployment/homebrew/packager.py" ${{ env.RELEASE_VERSION }} "./deployment/homebrew/managarr.rb.template" "./managarr.rb" ${{ env.MACOS_SHA }} ${{ env.MACOS_SHA_ARM }} ${{ env.LINUX_SHA }}
# - name: Checkout repository
# uses: actions/checkout@v4
# with:
# fetch-depth: 1
# - name: Get release artifacts - name: Push changes to Homebrew tap
# uses: actions/download-artifact@v3 if: env.ACT != 'true'
# with: env:
# name: artifacts TOKEN: ${{ secrets.MANAGARR_GITHUB_TOKEN }}
# path: artifacts run: |
# push to Git
git config --global user.name "Dark-Alex-17"
git config --global user.email "alex.j.tusa@gmail.com"
git clone https://Dark-Alex-17:${{ secrets.MANAGARR_GITHUB_TOKEN }}@github.com/Dark-Alex-17/homebrew-managarr.git
rm homebrew-managarr/Formula/managarr.rb
cp managarr.rb homebrew-managarr/Formula
cd homebrew-managarr
git add .
git diff-index --quiet HEAD || git commit -am "Update formula for Managarr release ${{ env.RELEASE_VERSION }}"
git push https://$TOKEN@github.com/Dark-Alex-17/homebrew-managarr.git
# - name: Set version variable publish-docker-image:
# run: | needs: [publish-github-release]
# version="$(cat artifacts/release-version)" name: Publishing Docker image to Docker Hub
# echo "version=$version" >> $GITHUB_ENV runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
# - name: Validate release environment variables - name: Checkout repository
# run: | uses: actions/checkout@v5
# echo "Release version: ${{ env.version }}" with:
fetch-depth: 1
# - name: Set up QEMU - name: Get release artifacts
# uses: docker/setup-qemu-action@v3 uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
# - name: Set up Docker Buildx - name: Ensure repository is up-to-date
# uses: docker/setup-buildx-action@v3 if: env.ACT != 'true'
run: |
git fetch --all
git pull
# - name: Login to Docker Hub - name: Set version variable
# uses: docker/login-action@v3 run: |
# with: version="$(cat artifacts/release-version)"
# username: ${{ secrets.DOCKER_USERNAME }} echo "version=$version" >> $GITHUB_ENV
# password: ${{ secrets.DOCKER_PASSWORD }}
# - name: Push to Docker Hub - name: Validate release environment variables
# uses: docker/build-push-action@v5 run: |
# with: echo "Release version: ${{ env.version }}"
# context: .
# file: Dockerfile
# platforms: linux/amd64,linux/arm64
# push: true
# tags: darkalex17/managarr:latest, darkalex17/managarr:${{ env.version }}
# publish-crate: - name: Set up QEMU
# needs: publish-github-release uses: docker/setup-qemu-action@v4
# name: Publish Crate
# runs-on: ubuntu-latest
# steps:
# - name: Check if actor is repository owner
# if: ${{ github.actor != github.repository_owner }}
# run: |
# echo "You are not authorized to run this workflow."
# exit 1
# - name: Checkout - name: Set up Docker Buildx
# uses: actions/checkout@v4 uses: docker/setup-buildx-action@v4
# with:
# fetch-depth: 0
# - name: Ensure repository is up-to-date - name: Login to Docker Hub
# run: | if: env.ACT != 'true'
# git fetch --all uses: docker/login-action@v4
# git pull with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
# - uses: actions/cache@v3 - name: Push to Docker Hub
# name: Cache Cargo registry uses: docker/build-push-action@v7
# with: with:
# path: ~/.cargo/registry context: .
# key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }} file: Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ env.ACT != 'true' }}
tags: darkalex17/managarr:latest, darkalex17/managarr:${{ env.version }}
# - uses: actions/cache@v3 publish-crate:
# with: needs: publish-github-release
# path: ~/.cargo/bin name: Publish Crate
# key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }} runs-on: ubuntu-latest
steps:
- name: Check if actor is repository owner
if: ${{ github.actor != github.repository_owner && env.ACT != 'true' }}
run: |
echo "You are not authorized to run this workflow."
exit 1
# - name: Install Rust stable - name: Checkout
# uses: dtolnay/rust-toolchain@stable uses: actions/checkout@v5
with:
fetch-depth: 0
# - uses: katyo/publish-crates@v2 - name: Get bumped Cargo files (Act)
# with: if: env.ACT == 'true'
# registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- name: Ensure repository is up-to-date
if: env.ACT != 'true'
run: |
git fetch --all
git pull
- uses: actions/cache@v5
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v5
with:
path: ~/.cargo/bin
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }}
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- uses: katyo/publish-crates@v2
if: env.ACT != 'true'
with:
registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }}
+44 -43
View File
@@ -33,7 +33,7 @@ jobs:
# toolchain # toolchain
toolchain: [stable, beta] toolchain: [stable, beta]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Install ${{ matrix.toolchain }} - name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
@@ -48,51 +48,51 @@ jobs:
- name: cargo test --locked - name: cargo test --locked
run: cargo test --locked --all-features --all-targets run: cargo test --locked --all-features --all-targets
minimal-versions: # minimal-versions:
# This action chooses the oldest version of the dependencies permitted by Cargo.toml to ensure # # This action chooses the oldest version of the dependencies permitted by Cargo.toml to ensure
# that this crate is compatible with the minimal version that this crate and its dependencies # # that this crate is compatible with the minimal version that this crate and its dependencies
# require. This will pickup issues where this create relies on functionality that was introduced # # require. This will pickup issues where this create relies on functionality that was introduced
# later than the actual version specified (e.g., when we choose just a major version, but a # # later than the actual version specified (e.g., when we choose just a major version, but a
# method was added after this version). # # method was added after this version).
# # #
# This particular check can be difficult to get to succeed as often transitive dependencies may # # This particular check can be difficult to get to succeed as often transitive dependencies may
# be incorrectly specified (e.g., a dependency specifies 1.0 but really requires 1.1.5). There # # be incorrectly specified (e.g., a dependency specifies 1.0 but really requires 1.1.5). There
# is an alternative flag available -Zdirect-minimal-versions that uses the minimal versions for # # is an alternative flag available -Zdirect-minimal-versions that uses the minimal versions for
# direct dependencies of this crate, while selecting the maximal versions for the transitive # # direct dependencies of this crate, while selecting the maximal versions for the transitive
# dependencies. Alternatively, you can add a line in your Cargo.toml to artificially increase # # dependencies. Alternatively, you can add a line in your Cargo.toml to artificially increase
# the minimal dependency, which you do with e.g.: # # the minimal dependency, which you do with e.g.:
# ```toml # # ```toml
# # for minimal-versions # # # for minimal-versions
# [target.'cfg(any())'.dependencies] # # [target.'cfg(any())'.dependencies]
# openssl = { version = "0.10.55", optional = true } # needed to allow foo to build with -Zminimal-versions # # openssl = { version = "0.10.55", optional = true } # needed to allow foo to build with -Zminimal-versions
# ``` # # ```
# The optional = true is necessary in case that dependency isn't otherwise transitively required # # The optional = true is necessary in case that dependency isn't otherwise transitively required
# by your library, and the target bit is so that this dependency edge never actually affects # # by your library, and the target bit is so that this dependency edge never actually affects
# Cargo build order. See also # # Cargo build order. See also
# https://github.com/jonhoo/fantoccini/blob/fde336472b712bc7ebf5b4e772023a7ba71b2262/Cargo.toml#L47-L49. # # https://github.com/jonhoo/fantoccini/blob/fde336472b712bc7ebf5b4e772023a7ba71b2262/Cargo.toml#L47-L49.
# This action is run on ubuntu with the stable toolchain, as it is not expected to fail # # This action is run on ubuntu with the stable toolchain, as it is not expected to fail
runs-on: ubuntu-latest # runs-on: ubuntu-latest
name: ubuntu / stable / minimal-versions # name: ubuntu / stable / minimal-versions
steps: # steps:
- uses: actions/checkout@v4 # - uses: actions/checkout@v5
- name: Install Rust stable # - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable # uses: dtolnay/rust-toolchain@stable
- name: Install nightly for -Zdirect-minimal-versions # - name: Install nightly for -Zdirect-minimal-versions
uses: dtolnay/rust-toolchain@nightly # uses: dtolnay/rust-toolchain@nightly
- name: rustup default stable # - name: rustup default stable
run: rustup default stable # run: rustup default stable
- name: cargo update -Zdirect-minimal-versions # - name: cargo update -Zdirect-minimal-versions
run: cargo +nightly update -Zdirect-minimal-versions # run: cargo +nightly update -Zdirect-minimal-versions
- name: cargo test # - name: cargo test
run: cargo test --locked --all-features --all-targets # run: cargo test --locked --all-features --all-targets
- name: Cache Cargo dependencies # - name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2 # uses: Swatinem/rust-cache@v2
os-check: os-check:
# run cargo test on mac and windows # run cargo test on mac and windows
@@ -110,7 +110,7 @@ jobs:
# - run: vcpkg install openssl:x64-windows-static-md # - run: vcpkg install openssl:x64-windows-static-md
# if: runner.os == 'Windows' # if: runner.os == 'Windows'
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -151,7 +151,7 @@ jobs:
name: ubuntu / stable / coverage name: ubuntu / stable / coverage
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Install Rust stable - name: Install Rust stable
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -175,7 +175,8 @@ jobs:
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
- name: Upload to codecov.io - name: Upload to codecov.io
uses: codecov/codecov-action@v4 if: env.ACT != 'true'
uses: codecov/codecov-action@v7
with: with:
fail_ci_if_error: true fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+3
View File
@@ -1,3 +1,6 @@
/target /target
/.idea/ /.idea/
/.scannerwork/ /.scannerwork/
/.act/
/.sisyphus/
/.coyote/
+5
View File
@@ -0,0 +1,5 @@
[keys.normal.backspace]
b = ":sh zellij run -x '4%%' --width '92%%' -f -n Build -- just build"
r = ":sh zellij run -x '3%%' -y '8%%' --width '95%%' --height '90%%' -fc -n 'Run' -- just run"
t = ":sh zellij run -x '4%%' --width '92%%' -f -n Tests -- just test"
l = ":sh zellij run -x '4%%' --width '92%%' -f -n Lint -- just lint"
+224
View File
@@ -5,6 +5,230 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## v0.7.3 (2026-06-25)
### Feat
- Implemented log rolling so the log file doesn't just grow exponentially [#60]
### Fix
- addressed code review comments
- tail-logs subcommand follows log rollovers and sleeps to minimize idle CPU loops
### Refactor
- Refactored several usages of sort_by_key and match guards to utilize newer Rust version APIs
## v0.7.2 (2026-04-20)
### Feat
- Created a separate 'ssl' property for the config so users don't have to specify an ssl_cert_path to use SSL or use the uri workaround for HTTPS API access
## v0.7.1 (2026-02-04)
### Feat
- Added support for a system-wide notification popup mechanism that works across Servarrs
- Implemented a 'config-path' command to print out the default Managarr configuration file path to help address #54
- Full support for filtering disks and aggregating root folders in the UI's 'Stats' block
- proper collapsing of root folder paths in the stats layer of the UI
- Added config option to filter for specific disk space paths to display in the UI (CLI is unaffected)
- Improved disk-space UI and CLI that shows the actual path being monitored instead of just a disk number
- Implemented the forgotten lidarr list disk-space command
### Fix
- Improved the system notification feature so it can persist between modals
- Sonarr API updated to somtimes allow either seeders or leechers to be null
- Improved the first-time run behavior so that it outputs the default configuration file it tries to load to help users locate the file on first-runs
- 'managarr config-path' should work without a pre-existing config already in place [#54]
### Refactor
- Removed the filtering of monitored_storage_paths from the networking module and migrated all of it to the UI
## v0.7.0 (2026-01-21)
### Feat
- Blocklist support in Lidarr in both the CLI and TUI
- CLI and TUI support for track history and track details in Lidarr
- Lidarr UI support for album details popup
- Implemented TUI handler support for the Album Details popup in Lidarr
- Bulk added CLI support for tracks and album functionalities in Lidarr
- Implemented the manual artist discography search tab in Lidarr's artist details UI
- Lidarr CLI support for downloading a release
- CLI support for searching for discography releases in Lidarr
- Added TUI and CLI support for viewing Artist history in Lidarr
- Full Lidarr system support for both the CLI and TUI
- Full CLI and TUI support for the Lidarr Indexers tab
- Full support for adding a root folder in Lidarr from both the CLI and TUI
- naive lidarr root folder tab implementation. Needs improved add logic
- Downloads tab support in Lidarr
- Created a History tab in the Radarr UI and created a list history command and mark-history-item-as-failed command for Radarr
- Implemented the Lidarr History tab and CLI support
- TUI support for deleting a Lidarr album from the artist details popup
- CLI support for deleting an album from Lidarr
- Completed support for viewing Lidarr artist details
- Full CLI and TUI support for adding an artist to Lidarr
- Include the Lidarr artist disambiguation in the title of the Edit Artist popup
- Initial Lidarr support for searching for new artists
- Lidarr CLI commands to list quality profiles and metadata profiles
- Improved CLI readability by creating a separate Global Options section for global flags
- CLI support for deleting a tag in Lidarr
- Lidarr CLI support for listing and adding tags
- Added CLI and TUI support for editing Lidarr artists
- Support for updating all Lidarr artists in both the CLI and TUI
- Added Lidarr CLI support for fetching the host config and the security config
- Created Lidarr commands: 'get artist-details' and 'get system-status'
- Fetch the artist members as part of the artist details query
- Support for toggling the monitoring of a given artist via the CLI and TUI
- Full support for deleting an artist via CLI and TUI
- TUI support for Lidarr library
- CLI support for listing artists
- Improved UI speed and responsiveness
### Fix
- Sonarr network wasn't checking for the user to be using the sorting block when populating season details
- Sonarr CLI was not properly filtering out episode and season releases when manually searching for releases
- Sonarr manual search TUI and CLI incorrectly displaying the same unfiltered results for both season and episode searches
- Slowed down the automatic text scrolling in tables so the text is readable
- Expanded the history item details size so that it can include all the available information for a given item; was previously being cut off on some screens
- Bug in submitting the update series prompt in the series details UI in Sonarr
- Don't include Lidarr artist disambiguation in Edit popup title when it is empty
- Refactored how quality profiles, language profiles, and metadata profiles are populated for each servarr so they sort using the ID to mimic the web UI better
- Added the correct keybinding context to the Lidarr edit artist popup
- Improved fault tolerance for search result tables and test all indexer results tables
- Prevented additional empty slice errors in indexer tables
- Fixed a bug in all Servarr implementations to not try to get the current selection of a search table when an error is returned from the API
- Fixed an issue with the Managarr table that would incorrectly try to display things before is_loading was ready
- Fixed a bug where the edit collection popup would not display when opening it from collection details
### Refactor
- Refactored the SonarrEvent enum to not unnecessarily wrap dual series_id and season_number values in a tuple when both values can be passed directly
- Improved and simplified the implementation of history details for both Sonarr and Lidarr
- Let serde serialize Add Series and Add Movie enums instead of calling to_string up front
- Use is_multiple_of for the tick counter in the UI module
- Updated all model tests to use purpose-built assertions to improve readability and maintainability
- Updated all handler tests to use purpose built assertions to improve readability and maintainability
- Used is_multiple_of to make life easier and cleaner in the app module
- Refactored all cli tests to use purpose-built assertions
- Improved test assertions in the app module
- Created dedicated proptests and assertions to clean up the handler unit tests
- Migrated the handle_table_events macro into a trait for better IDE support, created a TableEventAdapter wrapper for the KeyEventHandlers to make it so that the trait can be used properly and a simple function to replace the previous call to the handle_table_events macro
- Simplified both the table_handler macro and the stateful_table implementation
- Improved error handling for the tail-logs subcommand to propagate errors up the stack instead of exiting there.
- Added accessor methods to servarr_data structs, replaced for loops with functional iterator chains, eliminated mutable state tracking, and updated network module to use get_or_insert_default() for modal options
- Improved error handling project-wide and cleaned up some regexes with unnecessary escapes (tail_logs and interpolate_env_vars)
- Refactored to use more idiomatic let-else statements where applicable
## v0.6.3 (2025-12-13)
### Fix
- Wrapped all Sonarr use of Language with Option to fix the 'null' array issue in the new Sonarr API
## v0.6.2 (2025-12-12)
### Fix
- Fixed breaking Sonarr Episode file API calls after recent Sonarr API update
### Refactor
- Replaced all modulo usages of tick_until_poll with is_multiple_of
## v0.6.1 (2025-09-02)
### Fix
- Fixed UI bugs introduced as part of the hotkey refactor
### Refactor
- Updated crate to publish properly with the procedural macros
## v0.6.0 (2025-08-29)
### Feat
- Support for custom headers to be added to every request to each server to support alternative authentication mechanisms [#47]
- Refactor all keybinding tips into a dynamically changing menu that can be invoked via '?' [#32]
- Display total disk usage for series in the Library view to mirror Radarr functionality [#44]
- Pagination support for jumping 20 items at a time in all table views [#45]
- Support toggling Movie monitoring directly from the library view [#43]
- Support toggling Movie monitoring from the CLI
- Support toggling Series monitoring directly from the Sonarr library view [#43]
- Support toggling Series monitoring from the CLI
- Fixed the Radarr downloads tab to display more than 10 downloads at a time and added a new --count flag to the CLI for specifying the number of downloads to return
- Fetch more than 10 downloads when listing Sonarr downloads, and add a --count flag to the CLI to specify how many downloads to fetch
- Support alternative keymappings for all keys, featuring hjkl movements
- Added the Eldritch theme and updated documentation
- Write built in themes to the themes file on first run so users can define custom themes
- Created a theme validation macro to verify theme configurations before allowing the TUI to start
- Initial support for custom user-defined themes
### Fix
- Marked Radarr studios as nullable to prevent crashes
- Fixed a bug where the Sonarr API was returning empty values for seeders when searching for season releases
- Improve fault tolerance for tag associations in Radarr and Sonarr
- Upgraded to the most recent version of Tokio to mitigate CWE-664
- Updated all dependencies and updated openssl to the most recent version to mitigate CWE-416
- Updated the name of the should_ignore_quit_key to ignore_special_keys_for_textbox_input to give a better idea of what the flag is used for; also added alt keybinding for backspace
- Marked videoCodecs as Option to resolve #38
- Marked the `Season.statistics` field as `Option` so that a panic does not happen for outdated Sonarr data. This resolves #35
- When adding a film from the Collection Details modal, the render order was wrong: Radarr Library -> Collection Table -> Add Movie Prompt (missing the Collection details prompt too). Correct order is: Collection Table -> Collection Details Modal -> Add Movie Modal
- Fixed a bug that was rendering encompassing blocks after other widgets were rendered, thus overwriting the custom styles on each previously rendered widget
- change the name of the theme configuration file to 'themes'
- Ensure key events are only processed on key press to avoid duplicates
- Updated ring dependency to mitigate CWE-770
- Modified the Sonarr DownloadRecord so that the episode_id is optional to prevent crashes for weird downloads
### Refactor
- Network module is now broken out into similar directory structures for each servarr to mimic the rest of the project to make it easier to develop and maintain
- Refactored the IndexerTestResut model into the general Servarr models
- Renamed 'ctrl-*' keyboard shortcuts to 'C-*' to simplify and shrink the on-screen help
- Formatted files using rustfmt
- Reformatted code to make the format checks pass
- Created a derive macro for defining the display style of Enum models and removed the use of the EnumDisplayStyle trait
- Expanded the serde_enum_from macro to further reduce code duplication
## v0.5.1 (2025-03-01)
### Feat
- CLI Support for multiple Servarr instances
- Support for multiple servarr definitions - no tests [skip ci]
- Support for loading Servarr API tokens from a file
- Tweaked the implementation for environment variables in the config a bit
- var interpolation
### Fix
- Updated openssl to 0.10.70 to mitigate CVE-2025-24898
- Addressed rustfmt complaints
- Corrected typo in the managarr.nuspec.template
### Refactor
- Updated dependencies
- Addressed Cargo fmt complaints
- Added a debug line for logging to output the config used when starting Managarr
- Updated the 2018 idiom lint to the 2021_compatibility lint
- Removed unnecessary clones in the networking module to speed up network request handling
- Corrected some clone instead of copy behaviors in the command line handlers
- Removed unnecessary clone from stateful table
- Removed unnecessary clone call from extract_and_add_tag_ids_vec method
- Reduced the number of clones necessary when building modal structs
- Refactored a handful of Option calls to use take instead
- Renamed KeyEventHandler::with to KeyEventHandler::new to keep with Rust best practices and conventions
## v0.4.2 (2024-12-21) ## v0.4.2 (2024-12-21)
### Fix ### Fix
+46 -1
View File
@@ -1,6 +1,23 @@
# Contributing # Contributing
Contributors are very welcome! **No contribution is too small and all contributions are valued.** Contributors are very welcome! **No contribution is too small and all contributions are valued.**
## License and Attribution
#### _If you plan on contributing to the base project, no attribution is needed!_ Feel free to proceed to the [next steps](CONTRIBUTING.md#Rust).
Otherwise, below are key points to understand from the [Managarr License, Version 1.0](LICENSE):
1. **Non-Commercial Use**:
- Managarr is licensed solely for non-commercial purposes. Any commercial use of Managarr (e.g., selling or offering as a paid service) requires separate permission.
2. **Attribution when Forking and Redistributing Without Contributing back to Main Project**:
- **If you fork the project and distribute it separately** (e.g., publish or _publicly_ host it independently from the original project), you are required to provide attribution.
- You may credit the original author by using any of the following phrasing:
- "Thanks to Alexander J. Clarke (Dark-Alex-17) for creating the original Managarr project!"
- "Forked from the Managarr project, created by Alexander J. Clarke (Dark-Alex-17)"
- "This software is based on the original Managarr project by Alexander J. Clarke (Dark-Alex-17)"
- "Inspired by Alexander J. Clarke (Dark-Alex-17)'s Managarr project"
- If changes are made to the base Managarr project, please note those modifications and provide the new attribution accordingly.
## Rust ## Rust
You'll need to have the stable Rust toolchain installed in order to develop Managarr. You'll need to have the stable Rust toolchain installed in order to develop Managarr.
@@ -48,10 +65,38 @@ cz commit
1. Clone this repo 1. Clone this repo
2. Run `cargo test` to set up hooks 2. Run `cargo test` to set up hooks
3. Make changes 3. Make changes
4. Run the application using `make run` or `cargo run` 4. Run the application using `just run` or `just run`
- Install `just` (`cargo install just`) if you haven't already to use the [justfile](./justfile) in this project.
5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or warnings from Clippy, please fix them. 5. Commit changes. This will trigger pre-commit hooks that will run format, test and lint. If there are errors or warnings from Clippy, please fix them.
6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that will run lint and test. 6. Push your code to a new branch named after the feature/bug/etc. you're adding. This will trigger pre-push hooks that will run lint and test.
7. Create a PR 7. Create a PR
### CI/CD Testing with Act
If you also are planning on testing out your changes before pushing them with [Act](https://github.com/nektos/act), you will need to set up `act`,
`docker`, and configure your local system to run different architectures:
1. Install `docker` by following the instructions on the [official Docker installation page](https://docs.docker.com/get-docker/).
2. Install `act` by following the instructions on the [official Act installation page](https://nektosact.com/installation/index.html).
3. Install `binfmt` on your system once so that `act` can run the correct architecture for the CI/CD workflows.
You can do this by running:
```shell
sudo docker run --rm --privileged tonistiigi/binfmt --install all
```
Then, you can run workflows locally without having to commit and see if the GitHub action passes or fails.
**For example**: To test the [release.yml](.github/workflows/release.yml) workflow locally, you can run:
```shell
act -W .github/workflows/release.yml --input_type bump=minor
```
## Authorship Policy
All code in this repository is written and reviewed by humans, or AI-generated where explicitly disclosed. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is permitted only when explicitly disclosed and approved. For example: disclosing the use of AI-generated code in a PR description.
Submissions must certify that the contributor understands and can maintain the code they submit.
## Questions? Reach out to me! ## Questions? Reach out to me!
If you encounter any questions while developing Managarr, please don't hesitate to reach out to me at alex.j.tusa@gmail.com. I'm happy to help contributors, new and experienced in any way I can! If you encounter any questions while developing Managarr, please don't hesitate to reach out to me at alex.j.tusa@gmail.com. I'm happy to help contributors, new and experienced in any way I can!
Generated
+2392 -843
View File
File diff suppressed because it is too large. Load diff
+58 -39
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "managarr" name = "managarr"
version = "0.4.2" version = "0.7.3"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"] authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A TUI and CLI to manage your Servarrs" description = "A TUI and CLI to manage your Servarrs"
keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"] keywords = ["managarr", "ratatui", "dashboard", "servarr", "tui"]
@@ -8,62 +8,79 @@ documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr" repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr" homepage = "https://github.com/Dark-Alex-17/managarr"
readme = "README.md" readme = "README.md"
edition = "2021" edition = "2024"
license = "MIT" license = "MIT"
rust-version = "1.82.0" rust-version = "1.95.0"
exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"] exclude = [".github", "CONTRIBUTING.md", "*.log", "tags"]
[workspace]
members = [
"proc_macros/enum_display_style_derive",
"proc_macros/validate_theme_derive",
]
[dependencies] [dependencies]
anyhow = "1.0.68" anyhow = "1.0.100"
backtrace = "0.3.74" backtrace = "0.3.76"
bimap = { version = "0.6.3", features = ["serde"] } bimap = { version = "0.6.3", features = ["serde"] }
chrono = { version = "0.4.38", features = ["serde"] } chrono = { version = "0.4.43", features = ["serde"] }
confy = { version = "0.6.0", default-features = false, features = [ confy = { version = "2.0.0", default-features = false, features = [
"yaml_conf", "yaml_conf",
] } ] }
crossterm = "0.28.1" crossterm = "0.28.1"
derivative = "2.2.0" derivative = "2.2.0"
human-panic = "2.0.2" human-panic = "2.0.6"
indoc = "2.0.0" indoc = "2.0.7"
log = "0.4.17" log = "0.4.29"
log4rs = { version = "1.2.0", features = ["file_appender"] } log4rs = { version = "1.4.0", features = ["rolling_file_appender", "compound_policy", "size_trigger", "fixed_window_roller"] }
regex = "1.11.1" regex = "1.12.2"
reqwest = { version = "0.12.9", features = ["json"] } reqwest = { version = "0.13.2", features = ["json"] }
serde_yaml = "0.9.16" serde_yaml = "0.9.34"
serde_json = "1.0.91" serde_json = "1.0.149"
serde = { version = "1.0.214", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
strum = { version = "0.26.3", features = ["derive"] } strum = { version = "0.26.3", features = ["derive"] }
strum_macros = "0.26.4" strum_macros = "0.26.4"
tokio = { version = "1.36.0", features = ["full"] } tokio = { version = "1.49.0", features = ["full"] }
tokio-util = "0.7.8" tokio-util = "0.7.18"
ratatui = { version = "0.29.0", features = [ ratatui = { version = "0.30.0", features = [
"all-widgets", "all-widgets",
"unstable-widget-ref", "unstable-widget-ref",
] } ] }
urlencoding = "2.1.2" urlencoding = "2.1.3"
clap = { version = "4.5.20", features = ["derive", "cargo", "env"] } clap = { version = "4.5.56", features = [
clap_complete = "4.5.33" "derive",
itertools = "0.13.0" "cargo",
ctrlc = "3.4.5" "env",
colored = "2.1.0" "wrap_help",
async-trait = "0.1.83" ] }
clap_complete = "4.5.65"
itertools = "0.14.0"
ctrlc = "3.5.1"
colored = "3.1.1"
async-trait = "0.1.89"
dirs-next = "2.0.0" dirs-next = "2.0.0"
managarr-tree-widget = "0.24.0" managarr-tree-widget = "0.25.0"
indicatif = "0.17.9" indicatif = "0.17.11"
derive_setters = "0.1.6" derive_setters = "0.1.9"
deunicode = "1.6.0" deunicode = "1.6.2"
paste = "1.0.15" openssl = { version = "0.10.79", features = ["vendored"] }
openssl = { version = "0.10.68", features = ["vendored"] } veil = "0.2.0"
validate_theme_derive = "0.1.0"
enum_display_style_derive = "0.1.0"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.0.16" assert_cmd = "2.1.2"
mockall = "0.13.0" mockall = "0.13.1"
mockito = "1.0.0" mockito = "1.7.1"
pretty_assertions = "1.3.0" pretty_assertions = "1.4.1"
rstest = "0.23.0" proptest = "1.9.0"
rstest = "0.25.0"
serial_test = "3.3.1"
assertables = "9.8.4"
insta = "1.46.1"
[dev-dependencies.cargo-husky] [dev-dependencies.cargo-husky]
version = "1" version = "1.5.0"
default-features = false default-features = false
features = ["user-hooks"] features = ["user-hooks"]
@@ -74,4 +91,6 @@ name = "managarr"
[profile.release] [profile.release]
lto = true lto = true
codegen-units = 1 codegen-units = 3
opt-level = "s"
strip = true
+4 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.82 AS builder FROM rust:1.95 AS builder
WORKDIR /usr/src WORKDIR /usr/src
# Download and compile Rust dependencies in an empty project and cache as a separate Docker layer # Download and compile Rust dependencies in an empty project and cache as a separate Docker layer
@@ -6,6 +6,7 @@ RUN USER=root cargo new --bin managarr-temp
WORKDIR /usr/src/managarr-temp WORKDIR /usr/src/managarr-temp
COPY Cargo.* . COPY Cargo.* .
COPY proc_macros ./proc_macros
RUN cargo build --release RUN cargo build --release
# remove src from empty project # remove src from empty project
RUN rm -r src RUN rm -r src
@@ -20,6 +21,8 @@ RUN mv target/release/managarr .
FROM debian:stable-slim FROM debian:stable-slim
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
# Copy the compiled binary from the builder container # Copy the compiled binary from the builder container
COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin COPY --from=builder --chown=nonroot:nonroot /usr/src/managarr-temp/managarr /usr/local/bin
+46 -9
View File
@@ -1,16 +1,53 @@
MIT License Managarr License
Version 1.0, 2025
Copyright (c) 2023 Alexander J. Clarke Copyright (c) 2025 Alexander J. Clarke (Dark-Alex-17)
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to use,
in the Software without restriction, including without limitation the rights copy, modify, merge, publish, and distribute the Software solely for
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell non-commercial purposes, subject to the following conditions:
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all 1. Attribution:
copies or substantial portions of the Software. - The above copyright notice, this permission notice, and a prominent notice stating
that the Software is part of the "Managarr" project shall be included in all copies or
substantial portions of the Software **when the Software is forked and redistributed separately** from the original project.
- If you fork the software and **distribute it separately** without merging it back into the original base project (the Managarr repository), you must provide attribution to the original author.
You may use any of the following forms of attribution:
- "Thanks to Alexander J. Clarke (Dark-Alex-17) for creating the original Managarr project!"
- "Forked from the Managarr project, created by Alexander J. Clarke (Dark-Alex-17)"
- "This software is based on the original Managarr project by Alexander J. Clarke (Dark-Alex-17)"
- "Inspired by Alexander J. Clarke (Dark-Alex-17)'s Managarr project"
- If you modify the software, the attribution must also note that changes were made and describe those modifications, if feasible.
2. Non-Commercial Use Only:
The use of this Software for commercial purposes, including but not limited
to sale, licensing, or use in any product or service for monetary
compensation, is strictly prohibited without prior written permission from
Alexander J. Clarke (Dark-Alex-17).
For avoidance of doubt:
- **Allowed:** Private use, educational purposes, research, or any usage
that does not directly generate revenue.
- **Prohibited:** Selling, sublicensing, or incorporating the Software into
commercial products or services.
3. Modifications and Derivatives:
- Any modifications or derivative works based on this Software must clearly
document all changes made and prominently credit the original "Managarr"
project as described in section 1.
- Derivative works must retain this license, the original copyright notice,
and all terms and conditions described herein. This applies to the entire
derivative work, even if combined with other software.
4. Warranty Disclaimer:
This Software is provided "as is," without warranty of any kind, express
or implied, including but not limited to the warranties of merchantability,
fitness for a particular purpose, and noninfringement. In no event shall the
authors or copyright holders be liable for any claim, damages, or other
liability, whether in an action of contract, tort, or otherwise, arising
from, out of, or in connection with the Software or the use or other
dealings in the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-47
View File
@@ -1,47 +0,0 @@
#!make
VERSION := latest
IMG_NAME := darkalex17/managarr
IMAGE := ${IMG_NAME}:${VERSION}
default: run
.PHONY: test test-cov build run lint lint-fix fmt analyze sonar release delete-tag
test:
@cargo test
## Run all tests with coverage - `cargo install cargo-tarpaulin`
test-cov:
@cargo tarpaulin
build: test
@cargo build --release
docker:
@DOCKER_BUILDKIT=1 docker build --rm -t ${IMAGE} .
run:
@CARGO_INCREMENTAL=1 cargo fmt && make lint && cargo run
lint:
@find . | grep '\.\/src\/.*\.rs$$' | xargs touch && CARGO_INCREMENTAL=0 cargo clippy --all-targets --workspace
lint-fix:
@cargo fix
fmt:
@cargo fmt
minimal-versions:
@cargo +nightly update -Zdirect-minimal-versions
## Analyze for unsafe usage - `cargo install cargo-geiger`
analyze:
@cargo geiger
release:
@git tag -a ${V} -m "Release ${V}" && git push origin ${V}
delete-tag:
@git tag -d ${V} && git push --delete origin ${V}
+348 -62
View File
@@ -1,32 +1,37 @@
# managarr - A TUI and CLI to manage your Servarrs # managarr - A TUI and CLI to manage your Servarrs
![check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg) ![Check](https://github.com/Dark-Alex-17/managarr/actions/workflows/check.yml/badge.svg)
![test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg) ![Test](https://github.com/Dark-Alex-17/managarr/actions/workflows/test.yml/badge.svg)
![License](https://img.shields.io/badge/license-MIT-blueviolet.svg) ![License](https://img.shields.io/badge/license-MIT-blueviolet.svg)
![LOC](https://tokei.rs/b1/github/Dark-Alex-17/managarr?category=code)
[![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr) [![crates.io link](https://img.shields.io/crates/v/managarr.svg)](https://crates.io/crates/managarr)
![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff) ![Release](https://img.shields.io/github/v/release/Dark-Alex-17/managarr?color=%23c694ff)
[![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr) [![codecov](https://codecov.io/gh/Dark-Alex-17/managarr/graph/badge.svg?token=33G179TW67)](https://codecov.io/gh/Dark-Alex-17/managarr)
![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads) ![Crate.io downloads](https://img.shields.io/crates/d/managarr?label=Crate%20downloads)
[![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases) [![GitHub Downloads](https://img.shields.io/github/downloads/Dark-Alex-17/managarr/total.svg?label=GitHub%20downloads)](https://github.com/Dark-Alex-17/managarr/releases)
![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/managarr?label=Docker%20downloads) ![Docker pulls](https://img.shields.io/docker/pulls/darkalex17/managarr?label=Docker%20downloads)
[![Matrix](https://img.shields.io/matrix/managarr-room%3Amatrix.org?logo=matrix&server_fqdn=matrix.org&fetchMode=guest&style=social&label=Managarr%20Matrix%20Space&link=https%3A%2F%2Fmatrix.to%2F%23%2F%23managarr%3Amatrix.org)](https://matrix.to/#/#managarr:matrix.org)
Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust! Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built with 🤎 in Rust!
![library](screenshots/sonarr/sonarr_library.png) ![library](screenshots/lidarr/lidarr_library.png)
## What Servarrs are supported? ## What Servarrs are supported?
- [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr) - [x] ![radarr_logo](logos/radarr.png) [Radarr](https://wiki.servarr.com/radarr)
- [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr) - [x] ![sonarr_logo](logos/sonarr.png) [Sonarr](https://wiki.servarr.com/en/sonarr)
- [ ] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr) - [x] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr)
- [ ] ![lidarr_logo](logos/lidarr.png) [Lidarr](https://wiki.servarr.com/en/lidarr) - [x] ![readarr_logo](logos/readarr.png) [Readarr](https://wiki.servarr.com/en/readarr)
- [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr) - [ ] ![prowlarr_logo](logos/prowlarr.png) [Prowlarr](https://wiki.servarr.com/en/prowlarr)
- [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr) - [ ] ![whisparr_logo](logos/whisparr.png) [Whisparr](https://wiki.servarr.com/whisparr)
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/) - [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
- [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/) - [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/)
## Try Before You Buy > [!NOTE]
> The official Readarr repository has been archived, but Managarr's Readarr support is compatible with any Readarr forks
> that maintain compatibility with the official Readarr v1 API. See [Tested Readarr Forks](#tested-readarr-forks) below
> for forks that have been explicitly tested with Managarr, and any oddities in configuring them to work with Managarr.
## Try Out the Demo
To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository. To try out Managarr before linking it to your HTPC, you can use the purpose built [managarr-demo](https://github.com/Dark-Alex-17/managarr-demo) repository.
Simply run the following command to start a demo: Simply run the following command to start a demo:
@@ -54,14 +59,14 @@ Run Managarr as a docker container by mounting your `config.yml` file to `/root/
docker run --rm -it -v /home/aclarke/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr:latest docker run --rm -it -v /home/aclarke/.config/managarr/config.yml:/root/.config/managarr/config.yml darkalex17/managarr:latest
``` ```
You can also clone this repo and run `make docker` to build a docker image locally and run it using the above command. You can also clone this repo and run `just build-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 populate your configuration file first before starting the container. Otherwise, the container will fail to start.
**Note:** If you run into errors using relative file paths when mounting the volume with the configuration file, try using an absolute path. **Note:** If you run into errors using relative file paths when mounting the volume with the configuration file, try using an absolute path.
### Homebrew (Mac and Linux) ### Homebrew (Mac and Linux)
To install Managarr from Homebrew, install the Managarr tap and then you'll be able to install Managarr: To install Managarr from Homebrew, install the Managarr tap. Then you'll be able to install Managarr:
```shell ```shell
brew tap Dark-Alex-17/managarr brew tap Dark-Alex-17/managarr
@@ -76,6 +81,16 @@ To upgrade to a newer version of Managarr:
brew upgrade managarr brew upgrade managarr
``` ```
### Nix (Externally Maintained)
To install Managarr on NixOS, you can use the following command:
```shell
nix-env --install managarr
# Alternatively, for non-NixOS users, you can spawn a temporary shell with Managarr available like so:
nix-shell -p managarr
```
### Chocolatey (Windows) ### Chocolatey (Windows)
The Managarr Chocolatey package is located [here](https://community.chocolatey.org/packages/managarr). Please note that validation The Managarr Chocolatey package is located [here](https://community.chocolatey.org/packages/managarr). Please note that validation
of Chocolatey packages take quite some time, and thus the package may not be available immediately after a new release. of Chocolatey packages take quite some time, and thus the package may not be available immediately after a new release.
@@ -84,7 +99,7 @@ of Chocolatey packages take quite some time, and thus the package may not be ava
choco install managarr choco install managarr
# Some newer releases may require a version number, so you can specify it like so: # Some newer releases may require a version number, so you can specify it like so:
choco install managarr --version=0.4.1 choco install managarr --version=0.7.0
``` ```
To upgrade to the latest and greatest version of Managarr: To upgrade to the latest and greatest version of Managarr:
@@ -92,7 +107,7 @@ To upgrade to the latest and greatest version of Managarr:
choco upgrade managarr choco upgrade managarr
# To upgrade to a specific version: # To upgrade to a specific version:
choco upgrade managarr --version=0.4.2 choco upgrade managarr --version=0.7.0
``` ```
### Manual ### Manual
@@ -107,16 +122,16 @@ Binaries are available on the [releases](https://github.com/Dark-Alex-17/managar
#### Windows Instructions #### Windows Instructions
To use a binary from the releases page on Windows, do the following: To use a binary from the releases page on Windows, do the following:
1. Download the latest binary [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS. 1. Download the latest [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS.
2. Use 7-Zip or TarTool to unpack the Tar file. 2. Use 7-Zip or TarTool to unpack the Tar file.
3. Run the executable `managarr.exe`! 3. Run the executable `managarr.exe`!
#### Linux/MacOS Instructions #### Linux/MacOS Instructions
To use a binary from the releases page on Linux/MacOS, do the following: To use a binary from the releases page on Linux/MacOS, do the following:
1. Download the latest binary [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS. 1. Download the latest [binary](https://github.com/Dark-Alex-17/managarr/releases) for your OS.
2. `cd` to the directory where you downloaded the binary. 2. `cd` to the directory where you downloaded the binary.
3. Extract the binary with `tar -C /usr/local/bin -xzf managarr-<arch>.tar.gz` (NB: This may require `sudo`) 3. Extract the binary with `tar -C /usr/local/bin -xzf managarr-<arch>.tar.gz` (Note: This may require `sudo`)
4. Now you can run `managarr`! 4. Now you can run `managarr`!
## Features ## Features
@@ -170,13 +185,45 @@ Key:
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates | | ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks | | ✅ | ✅ | Manually trigger scheduled tasks |
### Readarr
- [ ] Support for Readarr
### Lidarr ### Lidarr
- [ ] Support for Lidarr | TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, tracks |
| ✅ | ✅ | View details of a specific artists, albums, or tracks including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add artists to your library |
| ✅ | ✅ | Delete artists, downloads, indexers, root folders, and track files |
| ✅ | ✅ | Trigger automatic searches for artists or albums |
| ✅ | ✅ | Trigger refresh and disk scan for artists and downloads |
| ✅ | ✅ | Manually search for full artist discographies or albums |
| ✅ | ✅ | Edit your artists and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Readarr
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, editions |
| ✅ | ✅ | View details of a specific author, book, or edition including description, history, downloaded file info |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add authors to your library |
| ✅ | ✅ | Delete authors, books, downloads, indexers, root folders, and book files |
| ✅ | ✅ | Trigger automatic searches for authors or books |
| ✅ | ✅ | Trigger refresh and disk scan for authors and downloads |
| ✅ | ✅ | Manually search for author releases or books |
| ✅ | ✅ | Edit your authors and indexers |
| ✅ | ✅ | Manage your tags |
| ✅ | ✅ | Manage your root folders |
| ✅ | ✅ | Manage your blocklist |
| ✅ | ✅ | View and browse logs, tasks, events queues, and updates |
| ✅ | ✅ | Manually trigger scheduled tasks |
### Whisparr ### Whisparr
@@ -194,6 +241,19 @@ Key:
- [ ] Support for Tautulli - [ ] Support for Tautulli
### Themes
Managarr ships with a few themes out of the box. Here's a few examples:
#### Default
![default](themes/default/manual_episode_search.png)
#### Dracula
![dracula](themes/dracula/manual_episode_search.png)
#### Watermelon Dark
![watermelon-dark](themes/watermelon-dark/manual_episode_search.png)
You can also create your own custom themes as well. To learn more about what themes are built-in to Managarr and how
to create your own custom themes, check out the [Themes README](themes/README.md).
### The Managarr CLI ### The Managarr CLI
Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs. Managarr can be used in one of two ways: As a TUI, or as a CLI for managing your Servarrs.
@@ -206,7 +266,7 @@ To see all available commands, simply run `managarr --help`:
```shell ```shell
$ managarr --help $ managarr --help
managarr 0.4.0 managarr 0.8.0
Alex Clarke <alex.j.tusa@gmail.com> Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs A TUI and CLI to manage your Servarrs
@@ -216,15 +276,36 @@ Usage: managarr [OPTIONS] [COMMAND]
Commands: Commands:
radarr Commands for manging your Radarr instance radarr Commands for manging your Radarr instance
sonarr Commands for manging your Sonarr instance sonarr Commands for manging your Sonarr instance
lidarr Commands for manging your Lidarr instance
readarr Commands for managing your Readarr instance
completions Generate shell completions for the Managarr CLI completions Generate shell completions for the Managarr CLI
tail-logs Tail Managarr logs tail-logs Tail Managarr logs
config-path Print the full path to the default configuration file.
This file can be changed to another location using the '--config-file' flag
help Print this message or the help of the given subcommand(s) help Print this message or the help of the given subcommand(s)
Options: Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help -h, --help Print help
-V, --version Print version -V, --version Print version
Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output
challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use; defaults to the
path shown by 'managarr config-path' [env:
MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env:
MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env:
MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify
the name of the instance configuration that you want to
use.
This is useful when you have multiple instances of the
same Servarr defined in your config file.
By default, if left empty, the first configured Servarr
instance listed in the config file will be used.
``` ```
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: 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:
@@ -253,12 +334,21 @@ Commands:
test-all-indexers Test all Sonarr indexers test-all-indexers Test all Sonarr indexers
toggle-episode-monitoring Toggle monitoring for the specified episode toggle-episode-monitoring Toggle monitoring for the specified episode
toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID toggle-season-monitoring Toggle monitoring for the specified season that corresponds to the specified series ID
toggle-series-monitoring Toggle monitoring for the specified series corresponding to the given series ID
help Print this message or the help of the given subcommand(s) help Print this message or the help of the given subcommand(s)
Options: Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config <CONFIG> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
-h, --help Print help -h, --help Print help
Global Options:
--disable-spinner Disable the spinner (can sometimes make parsing output challenging) [env: MANAGARR_DISABLE_SPINNER=]
--config-file <CONFIG_FILE> The Managarr configuration file to use [env: MANAGARR_CONFIG_FILE=]
--themes-file <THEMES_FILE> The Managarr themes file to use [env: MANAGARR_THEMES_FILE=]
--theme <THEME> The name of the Managarr theme to use [env: MANAGARR_THEME=]
--servarr-name <SERVARR_NAME> For multi-instance configurations, you need to specify the name of the instance configuration that you want to use.
This is useful when you have multiple instances of the same Servarr defined in your config file.
By default, if left empty, the first configured Servarr instance listed in the config file will be used.
``` ```
**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: **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:
@@ -270,23 +360,49 @@ $ managarr radarr list movies | jq '.[] | select(.title == "Ad Astra") | .id'
# Configuration # Configuration
Managarr assumes reasonable defaults to connect to each service (i.e. Radarr is on localhost:7878), 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. but all servers will require you to input the API token. This means that for each Servarr you configure,
if you define only the `api_token`, Managarr will assume the Servarr is running on `localhost` and on the
default port for that respective service. That is:
The configuration file is located somewhere different for each OS. | Servarr | Default Host | Default Port |
|---------|--------------|--------------|
| Radarr | `localhost` | 7878 |
| Sonarr | `localhost` | 8989 |
| Lidarr | `localhost` | 8686 |
| Readarr | `localhost` | 8787 |
### Linux > [!TIP]
``` > In general, all Servarrs store their API tokens under Settings -> General -> Security -> API Key in their web UIs.
$HOME/.config/managarr/config.yml
## Minimum Configuration Requirements
The following configuration file will connect to each Servarr running on localhost with their default ports. The only
requirement for each is the specification of an API token.
```yaml
radarr:
# Connect to Radarr running on localhost:7878
- api_token: <your-radarr-api-token-here>
sonarr:
# Connect to sonarr running on localhost:8989
- api_token: <your-sonarr-api-token-here>
lidarr:
# Connect to lidarr running on localhost:8686
- api_token: <your-lidarr-api-token-here>
readarr:
# Connect to readarr running on localhost:8787
- api_token: <your-readarr-api-token-here>
``` ```
### Mac ## Configuration File Location
```
$HOME/Library/Application Support/managarr/config.yml
```
### Windows The configuration file is located somewhere different for each OS, so run the following command to print out the default
``` location of the `managarr` configuration file for your system:
%APPDATA%/Roaming/managarr/config.yml
```shell
managarr config-path
``` ```
## Specify Which Configuration File to Use ## Specify Which Configuration File to Use
@@ -295,44 +411,163 @@ where you may have more than one instance of a given Servarr running. Thus, you
config file using the `--config` flag: config file using the `--config` flag:
```shell ```shell
managarr --config /path/to/config.yml managarr --config-file /path/to/config.yml
``` ```
### Example Configuration: ### Example Configuration:
```yaml ```yaml
theme: default
radarr: radarr:
host: 192.168.0.78 - host: 192.168.0.78
port: 7878 port: 7878
api_token: someApiToken1234567890 api_token: someApiToken1234567890
ssl_cert_path: /path/to/radarr.crt # Required to enable SSL ssl_cert_path: /path/to/radarr.crt # Use the specified SSL certificate to connect to this Servarr
# Enables SSL regardless of the value of the 'ssl'
# See the SSL Configuration section below for more information
- host: 192.168.0.79
port: 7878
api_token: someApiToken1234567890
ssl: true # Use SSL to connect to this Servarr
# This will assume that you have the SSL certificate installed to your system trust store
# See the SSL Configuration section below for more information
- uri: http://htpc.local/radarr # Example of using the 'uri' key instead of 'host' and 'port'
api_token: someApiToken1234567890
sonarr: sonarr:
uri: http://htpc.local/sonarr # Example of using the 'uri' key instead of 'host' and 'port' - host: 192.168.0.89
api_token: someApiToken1234567890 port: 8989
readarr: api_token_file: /root/.config/sonarr_api_token # Example of loading the API token from a file instead of hardcoding it in the configuration file
host: 192.168.0.87
port: 8787 - name: Anime Sonarr # An example of a custom name for a secondary Sonarr instance
host: 192.168.1.89
port: 8989
api_token: someApiToken1234567890 api_token: someApiToken1234567890
lidarr: lidarr:
host: 192.168.0.86 - host: 192.168.0.86
port: 8686
api_token: ${MY_LIDARR_API_TOKEN} # Example of configuring using environment variables
monitored_storage_paths: # Filter which Root Folders or Disk Storage you want displayed in the UI's 'Stats' block
# Note: Setting these values does not affect what shows up in the 'Root Folders' tab of the UI.
- /nfs # An example disk (i.e. '<servarr> list disk-space' command) you want displayed in the UI under 'Storage:'
- /media # An example root folder you want displayed in the UI
# Root folders collapse up to the super-directory to reduce duplication in the UI. For example:
# if you have root folders '/media/tv', '/media/cartoons' and '/media/reality', and you set this
# monitored path, the UI will show '/media/[tv,cartoons,reality]' under Root Folders
- host: 192.168.1.86
port: 8686 port: 8686
api_token: someApiToken1234567890 api_token: someApiToken1234567890
whisparr: ssl_cert_path: /path/to/lidarr_1.crt
host: 192.168.0.69 custom_headers: # Example of adding custom headers to all requests to the Servarr instance
port: 6969 traefik-auth-bypass-key: someBypassKey1234567890
SOME-OTHER-CUSTOM-HEADER: ${MY_CUSTOM_HEADER_VALUE}
readarr:
- name: Chaptarr (Ebooks) # Example of how to use the Readarr support with a Readarr fork like Chaptarr
uri: http://192.168.0.87:8789/readarr/gr/ebook
api_token: someApiToken1234567890 api_token: someApiToken1234567890
ssl_cert_path: /path/to/whisparr.crt ```
bazarr:
host: 192.168.0.67 ### SSL Configuration
port: 6767 If your Servarr is using SSL or self-signed certificates, you may need to specify additional configuration options to connect without issues.
**If your Servarr's domain CA is installed in the system's trust store:**
Then you can simply specify `ssl: true` and Managarr will be able to connect to your Servarr:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl: true
```
**If your Servarr's domain CA is not installed:**
You'll either need to specify the path to the certificate via the `ssl_cert_path` property, or you'll need to install the certificate into your system store.
To acquire the cert for your Servarr's domain, you can use the following command:
```shell
openssl s_client -show-certs -connect <your-servarr-domain.com>:<port> </dev/null |\
sed -n -e '/-.BEGIN/,/-.END/ p' > /path/to/your/servarr.pem
```
Now, you can either specify `ssl_cert_path: /path/to/your/servarr.pem`:
Example configuration with a certificate that's not installed to the system trust store:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl_cert_path: /path/to/your/certificate.crt
```
Or install the certificate into your system's trust store.
For example, if you're on a Debian-based system and have `ca-certificates` installed (`sudo apt install ca-certificates`):
```shell
sudo mv /path/to/your/servarr.pem /usr/local/share/ca-certificates/servarr.pem
sudo update-ca-certificates
```
Example configuration with a certificate that is installed to the system trust store:
```yaml
radarr:
- host: 192.168.0.78
port: 7878
api_token: yourApiTokenHere
ssl: true
```
### Example Multi-Instance Configuration:
```yaml
theme: default
radarr:
- host: 192.168.0.78 # No name specified, so this instance's name will default to 'Radarr 1'
port: 7878
api_token: someApiToken1234567890 api_token: someApiToken1234567890
prowlarr: ssl_cert_path: /path/to/radarr.crt
host: 192.168.0.96
port: 9696 - name: International Movies
host: 192.168.0.79
port: 7878
api_token: someApiToken1234567890 api_token: someApiToken1234567890
tautulli: sonarr:
host: 192.168.0.81 - name: Anime
port: 8181 weight: 1 # This instance will be the first tab in the TUI
uri: http://htpc.local/sonarr
api_token: someApiToken1234567890 api_token: someApiToken1234567890
- name: TV Shows
weight: 2 # This instance will be the second tab in the TUI
host: 192.168.0.89
port: 8989
api_token: someApiToken1234567890
```
In this configuration, you can see that we have multiple instances of Radarr and Sonarr configured. The `weight` key is
used to specify the order in which the tabs will appear in the TUI. The lower the weight, the further to the left the
tab will appear. If no weight is specified, then tabs will be ordered in the order they appear in the configuration
file.
When no `name` is specified for a Servarr instance, the name will default to the name of the Servarr with a number
appended to it. For example, if you have two Radarr instances and neither has a name, they will be named `Radarr 1` and
`Radarr 2`, respectively.
In this example configuration, the tabs in the TUI would appear as follows:
`Anime | TV Shows | Radarr 1 | International Movies`
### Specify Which Servarr Instance to Use in the CLI
If you have multiple instances of the same Servarr running, you can specify which instance you want to use by using the `--servarr-name` flag:
```shell
managarr --servarr-name "International Movies"
``` ```
## Environment Variables ## Environment Variables
@@ -343,8 +578,45 @@ Managarr supports using environment variables on startup so you don't have to al
| `MANAGARR_CONFIG_FILE` | Set the path to the config file | `--config` | | `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` | | `MANAGARR_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
## Track What I'm Currently Working On ## Tested Readarr Forks
To see what feature(s) I'm currently working on, check out my [Wekan Board](https://wekan.alexjclarke.com/b/dHoGjBb44MHM9HSv4/managarr).
The following Readarr forks have been explicitly tested with Managarr:
* [Bookshelf](https://github.com/pennydreadful/bookshelf)
* [Readarr-Resurrected](https://github.com/ricetim/readarr-rresurrected)
* [Librarr](https://github.com/Rorqualx/Librarr)
* [Chaptarr](https://github.com/Chaptarr/Chaptarr)
> [!IMPORTANT]
> Chaptarr houses a dedicated Readarr facade middleware that activates on a path prefix, meaning a bare config doesn't
> work. Instead, users need to configure both Audiobooks and Ebooks separately, and based on their desired metadata
> fetcher (i.e. Goodreads and Hardcover IDs). Because of this, Chaptarr-based configurations must use the `uri` key
> rather than the `host` and `port` keys. The below configuration file demonstrates all the supported variations for
> connecting Managarr to Chaptarr.
### Chaptarr Example Configuration
```yaml
readarr:
# Because Chaptarr supports both eBooks and audiobooks, the same instance must be provisioned twice if you're using
# both so Managarr can differentiate between the two. Thus, for Chaptarr, you need an eBook server configured and an
# audiobook server configured like below.
- name: eBooks (Goodreads) # Readarr originally used Goodreads as its metadata provider
uri: http://192.168.0.87:8789/readarr/gr/ebook # Note the `gr` in the path prefix indicating "GoodReads"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Goodreads)
uri: http://192.168.0.105:8789/readarr/gr/audiobook
api_token: someApiToken1234567890
- name: Chaptarr eBooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/ebook # Note the `hc` in the path prefix indicating "HardCover"
api_token: someApiToken1234567890
- name: Chaptarr Audiobooks (Hardcover)
uri: http://192.168.0.105:8789/readarr/hc/audiobook
api_token: someApiToken1234567890
```
## Screenshots ## Screenshots
@@ -361,6 +633,20 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
![season_details](screenshots/sonarr/season_details.png) ![season_details](screenshots/sonarr/season_details.png)
![manual_episode_search](screenshots/sonarr/manual_episode_search.png) ![manual_episode_search](screenshots/sonarr/manual_episode_search.png)
### Lidarr
![lidarr_library](screenshots/lidarr/lidarr_library.png)
![artist_details](screenshots/lidarr/artist_details.png)
![album_details](screenshots/lidarr/album_details.png)
![artist_discography_search](screenshots/lidarr/artist_discography_search.png)
![manual_album_search](screenshots/lidarr/manual_album_search.png)
### Readarr
![readarr_library](screenshots/readarr/readarr_library.png)
![author_details](screenshots/readarr/author_details.png)
![book_details](screenshots/readarr/book_details.png)
![manual_author_search](screenshots/readarr/manual_author_search.png)
![manual_album_search](screenshots/readarr/manual_book_search.png)
### General ### General
![logs](screenshots/radarr/logs.png) ![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png) ![indexers](screenshots/radarr/indexers.png)
@@ -376,8 +662,8 @@ To see what feature(s) I'm currently working on, check out my [Wekan Board](http
## Servarr Requirements ## Servarr Requirements
* [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/) * [Radarr >= 5.3.6.8612](https://radarr.video/docs/api/)
* [Sonarr >= v4](https://sonarr.tv/docs/api/) * [Sonarr >= v4](https://sonarr.tv/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Lidarr v1](https://lidarr.audio/docs/api/) * [Lidarr v1](https://lidarr.audio/docs/api/)
* [Readarr v1](https://readarr.com/docs/api/)
* [Whisparr >= v3](https://whisparr.com/docs/api/) * [Whisparr >= v3](https://whisparr.com/docs/api/)
* [Prowlarr v1](https://prowlarr.com/docs/api/) * [Prowlarr v1](https://prowlarr.com/docs/api/)
* [Bazarr v1.1.4](http://localhost:6767/api) * [Bazarr v1.1.4](http://localhost:6767/api)
+9 -1
View File
@@ -1,6 +1,14 @@
coverage: coverage:
range: "80..100" range: "80..100"
status:
project:
default:
threshold: 0
target: 80%
patch:
default:
threshold: 0
target: 80%
ignore: ignore:
- "**/*_tests.rs" - "**/*_tests.rs"
- "src/ui"
@@ -35,7 +35,7 @@ This is a nuspec. It mostly adheres to https://docs.nuget.org/create/Nuspec-Refe
<tags>cli cross-platform terminal servarr tui sonarr radarr rust</tags> <tags>cli cross-platform terminal servarr tui sonarr radarr rust</tags>
<summary>A TUI and CLI for managing *arr servers.</summary> <summary>A TUI and CLI for managing *arr servers.</summary>
<description> <description>
A TUI and CLI for managing *arr servers. Build with love in Rust! A TUI and CLI for managing *arr servers. Built with love in Rust!
**Usage** **Usage**
To use, run `managarr` in a terminal. To use, run `managarr` in a terminal.
+1 -1
View File
@@ -1,7 +1,7 @@
# Documentation: https://docs.brew.sh/Formula-Cookbook # Documentation: https://docs.brew.sh/Formula-Cookbook
# https://rubydoc.brew.sh/Formula # https://rubydoc.brew.sh/Formula
class Managarr < Formula class Managarr < Formula
desc "A fast and simple dashboard for Kubernetes written in Rust" desc "Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC)"
homepage "https://github.com/Dark-Alex-17/managarr" homepage "https://github.com/Dark-Alex-17/managarr"
if OS.mac? and Hardware::CPU.arm? if OS.mac? and Hardware::CPU.arm?
url "https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-macos-arm64.tar.gz" url "https://github.com/Dark-Alex-17/managarr/releases/download/v$version/managarr-macos-arm64.tar.gz"
+89
View File
@@ -0,0 +1,89 @@
VERSION := "latest"
IMG_NAME := "darkalex17/managarr"
# List all recipes
default:
@just --list
# Format all files
[group: 'style']
fmt:
@cargo fmt --all
alias clippy := lint
# Run Clippy to inspect all files
[group: 'style']
lint:
@cargo clippy --all
alias clippy-fix := lint-fix
# Automatically fix clippy issues where possible
[group: 'style']
lint-fix:
@cargo fix
# Analyze the project for unsafe usage
[group: 'style']
@analyze:
#!/usr/bin/env bash
cargo geiger -h > /dev/null 2>&1 | cargo install cargo-geiger
cargo geiger
# Run all tests
[group: 'test']
test:
@cargo test --all
# Run all tests with coverage
[group:'test']
@test-cov:
#!/usr/bin/env bash
cargo tarpaulin -h > /dev/null 2>&1 || cargo install cargo-tarpaulin
cargo tarpaulin
# Run all doc tests
[group: 'test']
doctest:
@cargo test --all --doc
# Run all proptests
[group: 'test']
proptest:
@cargo test -- property_tests proptest
alias test-snapshots := snapshot-tests
# Run all snapshot tests
[group: 'test']
snapshot-tests:
@cargo test snapshot
alias review := snapshot-review
# Review snapshot test changes
[group: 'test']
@snapshot-review:
#!/usr/bin/env bash
cargo insta -h > /dev/null 2>&1 || cargo install cargo-insta
cargo insta review
alias clean-orphaned-snapshots := snapshot-delete-unreferenced
# Delete any unreferenced snapshots
[group: 'test']
@snapshot-delete-unreferenced:
#!/usr/bin/env bash
cargo insta -h > /dev/null 2>&1 || cargo install cargo-insta
cargo insta test --unreferenced=delete
# Build and run the binary for the current system
run:
@cargo run
# Build the project for the current system architecture
[group: 'build']
[arg('build_type', pattern="debug|release")]
build build_type='debug':
@cargo build {{ if build_type == "release" { "--release" } else { "" } }}
# Build the docker image
[group: 'build']
build-docker version=VERSION:
@DOCKER_BUILDKIT=1 docker build --rm -t {{IMG_NAME}}:{{version}} .
@@ -0,0 +1,18 @@
[package]
name = "enum_display_style_derive"
version = "0.6.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A proc-macro to derive a `Display` and `FromStr` implementation for enums with a `style` attribute."
license = "MIT"
documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.39"
syn = "2.0.99"
darling = "0.20.10"
@@ -0,0 +1,76 @@
mod macro_models;
use crate::macro_models::DisplayStyleArgs;
use darling::FromVariant;
use quote::quote;
use syn::{Data, DeriveInput, parse_macro_input};
/// Derive macro for generating a `to_display_str` method for an enum.
///
/// # Example
///
/// Using default values for the display style:
///
/// ```
/// use enum_display_style_derive::EnumDisplayStyle;
///
/// #[derive(EnumDisplayStyle)]
/// enum Weekend {
/// Saturday,
/// Sunday,
/// }
///
/// assert_eq!(Weekend::Saturday.to_display_str(), "Saturday");
/// assert_eq!(Weekend::Sunday.to_display_str(), "Sunday");
/// ```
///
/// Using custom values for the display style:
///
/// ```
/// use enum_display_style_derive::EnumDisplayStyle;
///
/// #[derive(EnumDisplayStyle)]
/// enum MonitorStatus {
/// #[display_style(name = "Monitor Transactions")]
/// Active,
/// #[display_style(name = "Don't Monitor Transactions")]
/// None,
/// }
///
/// assert_eq!(MonitorStatus::Active.to_display_str(), "Monitor Transactions");
/// assert_eq!(MonitorStatus::None.to_display_str(), "Don't Monitor Transactions");
/// ```
#[proc_macro_derive(EnumDisplayStyle, attributes(display_style))]
pub fn enum_display_style_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let enum_name = &input.ident;
let mut match_arms = Vec::new();
if let Data::Enum(data_enum) = &input.data {
let variants = &data_enum.variants;
for variant in variants {
let variant_ident = &variant.ident;
let variant_display_name = DisplayStyleArgs::from_variant(variant)
.unwrap()
.name
.unwrap_or_else(|| variant_ident.to_string());
match_arms.push(quote! {
#enum_name::#variant_ident => #variant_display_name,
});
}
}
quote! {
impl<'a> #enum_name {
pub fn to_display_str(self) -> &'a str {
match self {
#(#match_arms)*
}
}
}
}
.into()
}
@@ -0,0 +1,7 @@
use darling::FromVariant;
#[derive(Debug, FromVariant)]
#[darling(attributes(display_style))]
pub struct DisplayStyleArgs {
pub name: Option<String>,
}
@@ -0,0 +1,20 @@
[package]
name = "validate_theme_derive"
version = "0.6.1"
edition = "2024"
authors = ["Alex Clarke <alex.j.tusa@gmail.com>"]
description = "A proc-macro to validate a theme."
license = "MIT"
documentation = "https://github.com/Dark-Alex-17/managarr"
repository = "https://github.com/Dark-Alex-17/managarr"
homepage = "https://github.com/Dark-Alex-17/managarr"
[lib]
proc-macro = true
[dependencies]
quote = "1.0.39"
syn = "2.0.99"
[dev-dependencies]
log = "0.4.17"
@@ -0,0 +1,106 @@
use proc_macro::TokenStream;
use quote::quote;
use syn::{Data, DeriveInput, Fields, parse_macro_input};
/// Derive macro for generating a `validate` method for a Theme struct.
/// The `validate` method ensures that all values with the `validate` attribute are not `None`.
/// Otherwise, an error message it output to both the log file and stdout and the program exits.
///
/// # Example
///
/// Valid themes pass through the program transitively without any messages being output.
///
/// ```
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// good: Some(Style { color: "Green".to_owned() }),
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..Theme::default()
/// };
///
/// // Since only `good` and `bad` have the `validate` attribute, the `validate` method will only check those fields.
/// theme.validate();
/// // Since both `good` and `bad` have values, the program will not exit and no message is output.
/// ```
///
/// Invalid themes will output an error message to both the log file and stdout and the program will exit.
///
/// ```should_panic
/// use validate_theme_derive::ValidateTheme;
///
/// #[derive(ValidateTheme, Default)]
/// struct Theme {
/// pub name: String,
/// #[validate]
/// pub good: Option<Style>,
/// #[validate]
/// pub bad: Option<Style>,
/// pub ugly: Option<Style>,
/// }
///
/// struct Style {
/// color: String,
/// }
///
/// let theme = Theme {
/// bad: Some(Style { color: "Red".to_owned() }),
/// ..Theme::default()
/// };
///
/// // Since `good` has the `validate` attribute and since `good` is `None`, the `validate` method will output an error message and exit the program.
/// theme.validate();
/// ```
#[proc_macro_derive(ValidateTheme, attributes(validate))]
pub fn derive_validate_theme(input: TokenStream) -> TokenStream {
let input = parse_macro_input!(input as DeriveInput);
let struct_name = &input.ident;
let mut validation_checks = Vec::new();
if let Data::Struct(data_struct) = &input.data
&& let Fields::Named(fields) = &data_struct.fields
{
for field in &fields.named {
let field_name = &field.ident;
let has_validate_attr = field
.attrs
.iter()
.any(|attr| attr.path().is_ident("validate"));
if has_validate_attr {
validation_checks.push(quote! {
if self.#field_name.is_none() {
log::error!("{} is missing a color value.", stringify!(#field_name));
eprintln!("{} is missing a color value.", stringify!(#field_name));
std::process::exit(1);
}
})
}
}
}
quote! {
impl #struct_name {
pub fn validate(&self) {
#(#validation_checks)*
}
}
}
.into()
}
@@ -0,0 +1,8 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 56330c025ad79db641d0eb9f429ab74e95822e1fb015b58f0e158ea674cd42a1 # shrinks to list_size = 1, page_ops = 1
cc 29df5ff471eb388b0ffde573a959a539e4cf32acd9e1cea5187bba2268dd57f7 # shrinks to list_size = 1, filter_term = "𑛀"
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc fb4b58aa3015a125fc33a78dfaf27981db4191247151b327a351fc445e07c231 # shrinks to input = "j"
cc d6ec17d4d3f635f0a095ade650a316d26abc1f9fe2b6d9cf67bf2f8b4ebedb60 # shrinks to backspace_count = 0
cc cd46ee46e18cf86c940fb89c7206f0b482909880b8f2eabe3dd20682b9912c8a # shrinks to input = "h"
@@ -0,0 +1,9 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc 24ae243412a324cb46c36cb4f629ddd4c9326b1479d1186d9b5545ac5e86dbba # shrinks to num_scroll_attempts = 0
cc c06a1cc1e4740b2498c50d7be64715bf09ef3ac4cf3bb3642f960578a3e06c74 # shrinks to is_loading = false, num_items = 1
cc 930207899afea2d389c7fa3974e31c2eb1803e71bcbd8179246c795903905ec7 # shrinks to parent_width = 20, parent_height = 12, percent_x = 1, percent_y = 1
+1 -1
View File
@@ -1,5 +1,5 @@
tab_spaces=2 tab_spaces=2
edition = "2021" edition = "2024"
reorder_imports = true reorder_imports = true
imports_granularity = "Crate" imports_granularity = "Crate"
group_imports = "StdExternalCrate" group_imports = "StdExternalCrate"
Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 382 KiB

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 258 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 203 KiB

After

Width:  |  Height:  |  Size: 220 KiB

+553 -85
View File
@@ -1,51 +1,91 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::models::Route;
use anyhow::anyhow; use anyhow::anyhow;
use pretty_assertions::assert_eq; use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest; use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde_json::Value;
use serial_test::serial;
use std::time::{Duration, Instant};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES}; use crate::app::{App, AppConfig, Data, ServarrConfig, interpolate_env_vars};
use crate::app::{App, AppConfig, Data, ServarrConfig}; use crate::models::servarr_data::lidarr::lidarr_data::LidarrData;
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::{HorizontallyScrollableText, TabRoute}; use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkEvent; use crate::network::NetworkEvent;
use crate::network::radarr_network::RadarrEvent;
use crate::network::sonarr_network::SonarrEvent;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
#[rstest] #[test]
fn test_app_new( fn test_app_new() {
#[values(ActiveRadarrBlock::default(), ActiveSonarrBlock::default())] servarr: impl Into<Route> let radarr_config_1 = ServarrConfig {
+ Copy, name: Some("Radarr Test".to_owned()),
) { ..ServarrConfig::default()
let (title, config) = match servarr.into() {
Route::Radarr(_, _) => (
"Radarr",
AppConfig {
radarr: Some(ServarrConfig::default()),
..AppConfig::default()
},
),
Route::Sonarr(_, _) => (
"Sonarr",
AppConfig {
sonarr: Some(ServarrConfig::default()),
..AppConfig::default()
},
),
_ => unreachable!(),
}; };
let tab_route = |title: &'static str| TabRoute { let radarr_config_2 = ServarrConfig {
title, weight: Some(3),
route: servarr.into(), ..ServarrConfig::default()
help: format!( };
"<↑↓> scroll | ←→ change tab | {} ", let sonarr_config_1 = ServarrConfig {
build_context_clue_string(&SERVARR_CONTEXT_CLUES) name: Some("Sonarr Test".to_owned()),
), weight: Some(1),
..ServarrConfig::default()
};
let sonarr_config_2 = ServarrConfig::default();
let readarr_config_1 = ServarrConfig {
name: Some("Readarr Test".to_owned()),
weight: Some(2),
..ServarrConfig::default()
};
let readarr_config_2 = ServarrConfig::default();
let config = AppConfig {
theme: None,
radarr: Some(vec![radarr_config_1.clone(), radarr_config_2.clone()]),
sonarr: Some(vec![sonarr_config_1.clone(), sonarr_config_2.clone()]),
lidarr: None,
readarr: Some(vec![readarr_config_1.clone(), readarr_config_2.clone()]),
};
let expected_tab_routes = vec![
TabRoute {
title: "Sonarr Test".to_owned(),
route: ActiveSonarrBlock::default().into(),
contextual_help: None, contextual_help: None,
}; config: Some(sonarr_config_1),
},
TabRoute {
title: "Readarr Test".to_owned(),
route: ActiveReadarrBlock::default().into(),
contextual_help: None,
config: Some(readarr_config_1),
},
TabRoute {
title: "Radarr 1".to_owned(),
route: ActiveRadarrBlock::default().into(),
contextual_help: None,
config: Some(radarr_config_2),
},
TabRoute {
title: "Radarr Test".to_owned(),
route: ActiveRadarrBlock::default().into(),
contextual_help: None,
config: Some(radarr_config_1),
},
TabRoute {
title: "Sonarr 1".to_owned(),
route: ActiveSonarrBlock::default().into(),
contextual_help: None,
config: Some(sonarr_config_2),
},
TabRoute {
title: "Readarr 1".to_owned(),
route: ActiveReadarrBlock::default().into(),
contextual_help: None,
config: Some(readarr_config_2),
},
];
let app = App::new( let app = App::new(
mpsc::channel::<NetworkEvent>(500).0, mpsc::channel::<NetworkEvent>(500).0,
@@ -53,21 +93,22 @@ mod tests {
CancellationToken::new(), CancellationToken::new(),
); );
assert!(app.navigation_stack.is_empty()); assert_is_empty!(app.navigation_stack);
assert_eq!(app.get_current_route(), servarr.into()); assert_eq!(app.get_current_route(), ActiveSonarrBlock::default().into());
assert!(app.network_tx.is_some()); assert_some!(app.network_tx);
assert!(!app.cancellation_token.is_cancelled()); assert!(!app.cancellation_token.is_cancelled());
assert!(app.is_first_render); assert!(app.is_first_render);
assert_eq!(app.error, HorizontallyScrollableText::default()); assert_eq!(app.error, HorizontallyScrollableText::default());
assert_eq!(app.server_tabs.index, 0); assert_eq!(app.server_tabs.index, 0);
assert_eq!(app.server_tabs.tabs, vec![tab_route(title)]); assert_eq!(app.server_tabs.tabs, expected_tab_routes);
assert_eq!(app.tick_until_poll, 400); assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 4); assert_eq!(app.scroll_interval, Duration::from_millis(100));
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
assert!(!app.should_text_scroll);
assert!(!app.is_loading); assert!(!app.is_loading);
assert!(!app.is_routing); assert!(!app.is_routing);
assert!(!app.should_refresh); assert!(!app.should_refresh);
assert!(!app.should_ignore_quit_key); assert!(!app.ignore_special_keys_for_textbox_input);
assert!(!app.cli_mode); assert!(!app.cli_mode);
} }
@@ -75,48 +116,25 @@ mod tests {
fn test_app_default() { fn test_app_default() {
let app = App::default(); let app = App::default();
assert!(app.navigation_stack.is_empty()); assert_is_empty!(app.navigation_stack);
assert!(app.network_tx.is_none()); assert_none!(app.network_tx);
assert!(!app.cancellation_token.is_cancelled()); assert!(!app.cancellation_token.is_cancelled());
assert!(app.is_first_render); assert!(app.is_first_render);
assert_eq!(app.error, HorizontallyScrollableText::default()); assert_eq!(app.error, HorizontallyScrollableText::default());
assert_eq!(app.server_tabs.index, 0); assert_eq!(app.server_tabs.index, 0);
assert_eq!(
app.server_tabs.tabs,
vec![
TabRoute {
title: "Radarr",
route: ActiveRadarrBlock::Movies.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
TabRoute {
title: "Sonarr",
route: ActiveSonarrBlock::Series.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
]
);
assert_eq!(app.tick_until_poll, 400); assert_eq!(app.tick_until_poll, 400);
assert_eq!(app.ticks_until_scroll, 4); assert_eq!(app.scroll_interval, Duration::from_millis(100));
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
assert!(!app.is_loading); assert!(!app.is_loading);
assert!(!app.is_routing); assert!(!app.is_routing);
assert!(!app.should_refresh); assert!(!app.should_refresh);
assert!(!app.should_ignore_quit_key); assert!(!app.ignore_special_keys_for_textbox_input);
assert!(!app.cli_mode); assert!(!app.cli_mode);
} }
#[test] #[test]
fn test_navigation_stack_methods() { fn test_navigation_stack_methods() {
let mut app = App::default(); let mut app = App::test_default();
let default_route = app.server_tabs.tabs.first().unwrap().route; let default_route = app.server_tabs.tabs.first().unwrap().route;
assert_eq!(app.get_current_route(), default_route); assert_eq!(app.get_current_route(), default_route);
@@ -148,12 +166,42 @@ mod tests {
assert!(app.is_routing); assert!(app.is_routing);
} }
#[tokio::test]
async fn test_named_tab_uses_selected_servarr_route_and_network_events() {
let (network_tx, mut network_rx) = mpsc::channel(500);
let mut app = App::new(
network_tx,
AppConfig {
radarr: Some(vec![ServarrConfig {
name: Some("Movies".to_owned()),
..ServarrConfig::default()
}]),
sonarr: Some(vec![ServarrConfig {
name: Some("Shows".to_owned()),
..ServarrConfig::default()
}]),
..AppConfig::default()
},
CancellationToken::new(),
);
assert!(app.server_tabs.select_tab_by_title("Shows"));
assert_eq!(app.get_current_route(), ActiveSonarrBlock::Series.into());
app.on_tick().await;
assert_eq!(
network_rx.recv().await.unwrap(),
SonarrEvent::GetQualityProfiles.into()
);
}
#[test] #[test]
fn test_reset_cancellation_token() { fn test_reset_cancellation_token() {
let mut app = App { let mut app = App {
is_loading: true, is_loading: true,
should_refresh: false, should_refresh: false,
..App::default() ..App::test_default()
}; };
app.cancellation_token.cancel(); app.cancellation_token.cancel();
@@ -171,7 +219,7 @@ mod tests {
fn test_reset_tick_count() { fn test_reset_tick_count() {
let mut app = App { let mut app = App {
tick_count: 2, tick_count: 2,
..App::default() ..App::test_default()
}; };
app.reset_tick_count(); app.reset_tick_count();
@@ -190,7 +238,9 @@ mod tests {
..SonarrData::default() ..SonarrData::default()
}; };
let data = Data { let data = Data {
lidarr_data: LidarrData::default(),
radarr_data, radarr_data,
readarr_data: ReadarrData::default(),
sonarr_data, sonarr_data,
}; };
let mut app = App { let mut app = App {
@@ -198,7 +248,7 @@ mod tests {
error: "Test error".to_owned().into(), error: "Test error".to_owned().into(),
is_first_render: false, is_first_render: false,
data, data,
..App::default() ..App::test_default()
}; };
app.reset(); app.reset();
@@ -212,7 +262,7 @@ mod tests {
#[test] #[test]
fn test_handle_error() { fn test_handle_error() {
let mut app = App::default(); let mut app = App::test_default();
let test_string = "Testing"; let test_string = "Testing";
app.handle_error(anyhow!(test_string)); app.handle_error(anyhow!(test_string));
@@ -231,7 +281,7 @@ mod tests {
let mut app = App { let mut app = App {
tick_until_poll: 2, tick_until_poll: 2,
network_tx: Some(sync_network_tx), network_tx: Some(sync_network_tx),
..App::default() ..App::test_default()
}; };
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -247,6 +297,25 @@ mod tests {
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
} }
#[test]
fn test_on_ui_scroll_tick() {
let mut app = App {
scroll_interval: Duration::ZERO,
..App::default()
};
app.on_ui_scroll_tick();
assert!(app.should_text_scroll);
app.on_ui_scroll_tick();
assert!(app.should_text_scroll);
app.scroll_interval = Duration::from_secs(60);
app.last_scroll = Instant::now();
app.on_ui_scroll_tick();
assert!(!app.should_text_scroll);
}
#[tokio::test] #[tokio::test]
async fn test_on_tick_first_render() { async fn test_on_tick_first_render() {
let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500); let (sync_network_tx, mut sync_network_rx) = mpsc::channel::<NetworkEvent>(500);
@@ -255,7 +324,7 @@ mod tests {
tick_until_poll: 2, tick_until_poll: 2,
network_tx: Some(sync_network_tx), network_tx: Some(sync_network_tx),
is_first_render: true, is_first_render: true,
..App::default() ..App::test_default()
}; };
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -276,7 +345,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
@@ -309,7 +378,7 @@ mod tests {
tick_until_poll: 2, tick_until_poll: 2,
tick_count: 2, tick_count: 2,
is_routing: true, is_routing: true,
..App::default() ..App::test_default()
}; };
app.on_tick().await; app.on_tick().await;
@@ -322,7 +391,7 @@ mod tests {
tick_until_poll: 2, tick_until_poll: 2,
tick_count: 2, tick_count: 2,
should_refresh: true, should_refresh: true,
..App::default() ..App::test_default()
}; };
app.on_tick().await; app.on_tick().await;
@@ -333,18 +402,417 @@ mod tests {
fn test_app_config_default() { fn test_app_config_default() {
let app_config = AppConfig::default(); let app_config = AppConfig::default();
assert!(app_config.radarr.is_none()); assert_none!(app_config.radarr);
assert!(app_config.sonarr.is_none()); assert_none!(app_config.sonarr);
} }
#[test] #[test]
fn test_servarr_config_default() { fn test_servarr_config_default() {
let servarr_config = ServarrConfig::default(); let servarr_config = ServarrConfig::default();
assert_eq!(servarr_config.host, Some("localhost".to_string())); assert_none!(servarr_config.name);
assert_eq!(servarr_config.port, None); assert_some_eq_x!(&servarr_config.host, "localhost");
assert_eq!(servarr_config.uri, None); assert_none!(servarr_config.port);
assert!(servarr_config.api_token.is_empty()); assert_none!(servarr_config.uri);
assert_eq!(servarr_config.ssl_cert_path, None); assert_none!(servarr_config.weight);
assert_some_eq_x!(&servarr_config.api_token, "");
assert_none!(servarr_config.api_token_file);
assert_none!(servarr_config.ssl_cert_path);
assert_none!(servarr_config.custom_headers);
}
#[test]
fn serialize_header_map_basic() {
let mut header_map = HeaderMap::new();
header_map.insert(
HeaderName::from_static("x-api-key"),
HeaderValue::from_static("abc123"),
);
header_map.insert(
HeaderName::from_static("header-1"),
HeaderValue::from_static("test"),
);
let config = ServarrConfig {
custom_headers: Some(header_map),
..ServarrConfig::default()
};
let v: Value = serde_json::to_value(&config).expect("serialize ok");
let custom = v.get("custom_headers").unwrap();
assert!(custom.is_object());
let obj = custom.as_object().unwrap();
assert_some_eq_x!(obj.get("x-api-key"), "abc123");
assert_some_eq_x!(obj.get("header-1"), "test");
assert_none!(obj.get("X-Api-Key"));
assert_none!(obj.get("HEADER-1"));
}
#[test]
fn serialize_header_map_none_is_null() {
let config = ServarrConfig::default();
let v: Value = serde_json::to_value(&config).expect("serialize ok");
assert!(v.get("custom_headers").unwrap().is_null());
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION", "localhost") };
let yaml_data = r#"
host: ${TEST_VAR_DESERIALIZE_OPTION}
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.host, "localhost");
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_does_not_overwrite_non_env_value() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE", "localhost") };
let yaml_data = r#"
host: www.example.com
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.host, "www.example.com");
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.port);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_bool() {
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: true
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_string() {
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: "true"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_BOOL", "true") };
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: ${TEST_VAR_DESERIALIZE_OPTION_BOOL}
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &true);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_BOOL") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_bool_defaults_to_false() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY", "test") };
let yaml_data = r#"
host: localhost
api_token: "test123"
ssl: ${TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY}
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.ssl, &false);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_BOOL_FALSEY") };
}
#[test]
fn test_deserialize_optional_env_var_bool_empty() {
let yaml_data = r#"
host: localhost
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.ssl);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_header_map_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_HEADER_OPTION", "localhost") };
let expected_custom_headers = {
let mut headers = HeaderMap::new();
headers.insert("X-Api-Host", "localhost".parse().unwrap());
headers.insert("api-token", "test123".parse().unwrap());
headers
};
let yaml_data = r#"
custom_headers:
X-Api-Host: ${TEST_VAR_DESERIALIZE_HEADER_OPTION}
api-token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.custom_headers, &expected_custom_headers);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_HEADER_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_header_map_does_not_overwrite_non_env_value() {
unsafe {
std::env::set_var(
"TEST_VAR_DESERIALIZE_HEADER_OPTION_NO_OVERWRITE",
"localhost",
)
};
let expected_custom_headers = {
let mut headers = HeaderMap::new();
headers.insert("X-Api-Host", "www.example.com".parse().unwrap());
headers.insert("api-token", "test123".parse().unwrap());
headers
};
let yaml_data = r#"
custom_headers:
X-Api-Host: www.example.com
api-token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.custom_headers, &expected_custom_headers);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_HEADER_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_header_map_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.custom_headers);
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_string_vec_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION", "/path1") };
let expected_monitored_paths = ["/path1", "/path2"];
let yaml_data = r#"
monitored_storage_paths:
- ${TEST_VAR_DESERIALIZE_STRING_VEC_OPTION}
- /path2
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.monitored_storage_paths, &expected_monitored_paths);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION") };
}
#[test]
#[serial]
fn test_deserialize_optional_env_var_string_vec_does_not_overwrite_non_env_value() {
unsafe {
std::env::set_var(
"TEST_VAR_DESERIALIZE_STRING_VEC_OPTION_NO_OVERWRITE",
"/path3",
)
};
let expected_monitored_paths = ["/path1", "/path2"];
let yaml_data = r#"
monitored_storage_paths:
- /path1
- /path2
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(&config.monitored_storage_paths, &expected_monitored_paths);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_STRING_VEC_OPTION_NO_OVERWRITE") };
}
#[test]
fn test_deserialize_optional_env_var_string_vec_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.monitored_storage_paths);
}
#[test]
#[serial]
fn test_deserialize_optional_u16_env_var_is_present() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_U16", "1") };
let yaml_data = r#"
port: ${TEST_VAR_DESERIALIZE_OPTION_U16}
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(config.port, 1);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_U16") };
}
#[test]
#[serial]
fn test_deserialize_optional_u16_env_var_does_not_overwrite_non_env_value() {
unsafe { std::env::set_var("TEST_VAR_DESERIALIZE_OPTION_U16_UNUSED", "1") };
let yaml_data = r#"
port: 1234
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_some_eq_x!(config.port, 1234);
unsafe { std::env::remove_var("TEST_VAR_DESERIALIZE_OPTION_U16_UNUSED") };
}
#[test]
fn test_deserialize_optional_u16_env_var_invalid_number() {
let yaml_data = r#"
port: "hi"
api_token: "test123"
"#;
let result: Result<ServarrConfig, _> = serde_yaml::from_str(yaml_data);
assert_err!(&result);
let err = result.unwrap_err().to_string();
assert_contains!(err, "invalid digit found in string");
}
#[test]
fn test_deserialize_optional_u16_env_var_empty() {
let yaml_data = r#"
api_token: "test123"
"#;
let config: ServarrConfig = serde_yaml::from_str(yaml_data).unwrap();
assert_none!(config.port);
}
#[test]
#[serial]
fn test_interpolate_env_vars() {
unsafe { std::env::set_var("TEST_VAR_INTERPOLATION", "testing") };
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION}");
assert_str_eq!(var, "testing");
unsafe { std::env::remove_var("TEST_VAR_INTERPOLATION") };
}
#[test]
fn test_interpolate_env_vars_defaults_to_original_string_if_not_in_yaml_interpolation_format() {
let var = interpolate_env_vars("TEST_VAR_INTERPOLATION_NON_YAML");
assert_str_eq!(var, "TEST_VAR_INTERPOLATION_NON_YAML");
}
#[test]
#[serial]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters() {
unsafe {
std::env::set_var(
"TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS",
r#"""
`"'https://dontdo:this@testing.com/query?test=%20query#results'"` {([\|$!])}
"""#,
)
};
let var = interpolate_env_vars("${TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS}");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
unsafe { std::env::remove_var("TEST_VAR_INTERPOLATION_UNNECESSARY_CHARACTERS") };
}
#[test]
fn test_interpolate_env_vars_scrubs_all_unnecessary_characters_from_non_environment_variable() {
let var = interpolate_env_vars("https://dontdo:this@testing.com/query?test=%20query#results");
assert_str_eq!(
var,
"https://dontdo:this@testing.com/query?test=%20query#results"
);
}
#[test]
fn test_servarr_config_redacted_debug() {
let name = "Servarr".to_owned();
let host = "localhost".to_owned();
let port = 1234;
let uri = "http://localhost:1234".to_owned();
let weight = 100;
let api_token = "thisisatest".to_owned();
let api_token_file = "/root/.config/api_token".to_owned();
let ssl_cert_path = "/some/path".to_owned();
let monitored_storage = vec!["/path1".to_owned(), "/path2".to_owned()];
let mut custom_headers = HeaderMap::new();
custom_headers.insert("X-Custom-Header", "value".parse().unwrap());
let expected_str = format!(
"ServarrConfig {{ name: Some(\"{name}\"), host: Some(\"{host}\"), port: Some({port}), uri: Some(\"{uri}\"), weight: Some({weight}), api_token: Some(\"***********\"), api_token_file: Some(\"{api_token_file}\"), ssl: Some(true), ssl_cert_path: Some(\"{ssl_cert_path}\"), custom_headers: Some({{\"x-custom-header\": \"value\"}}), monitored_storage_paths: Some([\"/path1\", \"/path2\"]) }}"
);
let servarr_config = ServarrConfig {
name: Some(name),
host: Some(host),
port: Some(port),
uri: Some(uri),
weight: Some(weight),
api_token: Some(api_token),
api_token_file: Some(api_token_file),
ssl_cert_path: Some(ssl_cert_path),
ssl: Some(true),
custom_headers: Some(custom_headers),
monitored_storage_paths: Some(monitored_storage),
};
assert_str_eq!(format!("{servarr_config:?}"), expected_str);
} }
} }
+52 -9
View File
@@ -1,20 +1,45 @@
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS}; use crate::app::App;
use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding};
use crate::app::lidarr::lidarr_context_clues::LidarrContextClueProvider;
use crate::app::radarr::radarr_context_clues::RadarrContextClueProvider;
use crate::app::readarr::readarr_context_clues::ReadarrContextClueProvider;
use crate::app::sonarr::sonarr_context_clues::SonarrContextClueProvider;
use crate::models::Route;
#[cfg(test)] #[cfg(test)]
#[path = "context_clues_tests.rs"] #[path = "context_clues_tests.rs"]
mod context_clues_tests; mod context_clues_tests;
pub(in crate::app) type ContextClue = (KeyBinding, &'static str); pub type ContextClue = (KeyBinding, &'static str);
pub fn build_context_clue_string(context_clues: &[(KeyBinding, &str)]) -> String { pub trait ContextClueProvider {
context_clues fn get_context_clues(_app: &mut App<'_>) -> Option<&'static [ContextClue]>;
.iter()
.map(|(key_binding, desc)| format!("{} {desc}", key_binding.key))
.collect::<Vec<String>>()
.join(" | ")
} }
pub static SERVARR_CONTEXT_CLUES: [ContextClue; 3] = [ pub struct ServarrContextClueProvider;
impl ContextClueProvider for ServarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
match app.get_current_route() {
Route::Radarr(_, _) => RadarrContextClueProvider::get_context_clues(app),
Route::Sonarr(_, _) => SonarrContextClueProvider::get_context_clues(app),
Route::Lidarr(_, _) => LidarrContextClueProvider::get_context_clues(app),
Route::Readarr(_, _) => ReadarrContextClueProvider::get_context_clues(app),
_ => None,
}
}
}
pub static SERVARR_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.up, "scroll up"),
(DEFAULT_KEYBINDINGS.down, "scroll down"),
(DEFAULT_KEYBINDINGS.left, "previous tab"),
(DEFAULT_KEYBINDINGS.right, "next tab"),
(DEFAULT_KEYBINDINGS.pg_up, DEFAULT_KEYBINDINGS.pg_up.desc),
(
DEFAULT_KEYBINDINGS.pg_down,
DEFAULT_KEYBINDINGS.pg_down.desc,
),
( (
DEFAULT_KEYBINDINGS.next_servarr, DEFAULT_KEYBINDINGS.next_servarr,
DEFAULT_KEYBINDINGS.next_servarr.desc, DEFAULT_KEYBINDINGS.next_servarr.desc,
@@ -24,6 +49,7 @@ pub static SERVARR_CONTEXT_CLUES: [ContextClue; 3] = [
DEFAULT_KEYBINDINGS.previous_servarr.desc, DEFAULT_KEYBINDINGS.previous_servarr.desc,
), ),
(DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc), (DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc),
(DEFAULT_KEYBINDINGS.help, DEFAULT_KEYBINDINGS.help.desc),
]; ];
pub static BARE_POPUP_CONTEXT_CLUES: [ContextClue; 1] = pub static BARE_POPUP_CONTEXT_CLUES: [ContextClue; 1] =
@@ -78,6 +104,18 @@ pub static INDEXERS_CONTEXT_CLUES: [ContextClue; 6] = [
), ),
]; ];
pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [ pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.tasks, "open tasks"), (DEFAULT_KEYBINDINGS.tasks, "open tasks"),
(DEFAULT_KEYBINDINGS.events, "open events"), (DEFAULT_KEYBINDINGS.events, "open events"),
@@ -88,3 +126,8 @@ pub static SYSTEM_CONTEXT_CLUES: [ContextClue; 5] = [
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
), ),
]; ];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "start task"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
+261 -147
View File
@@ -1,212 +1,326 @@
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use pretty_assertions::{assert_eq, assert_str_eq};
use crate::app::context_clues::{ use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
DOWNLOADS_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES, INDEXERS_CONTEXT_CLUES,
SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SERVARR_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
SYSTEM_TASKS_CONTEXT_CLUES, ServarrContextClueProvider,
}; };
use crate::app::{context_clues::build_context_clue_string, key_binding::DEFAULT_KEYBINDINGS}; use crate::app::{App, key_binding::DEFAULT_KEYBINDINGS};
use crate::models::servarr_data::ActiveKeybindingBlock;
#[test] use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
fn test_build_context_clue_string() { use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
let test_context_clues_array = [ use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
(DEFAULT_KEYBINDINGS.add, "add"),
(DEFAULT_KEYBINDINGS.delete, "delete"),
];
assert_str_eq!(
build_context_clue_string(&test_context_clues_array),
"<a> add | <del> delete"
);
}
#[test] #[test]
fn test_servarr_context_clues() { fn test_servarr_context_clues() {
let mut servarr_context_clues_iter = SERVARR_CONTEXT_CLUES.iter(); let mut servarr_context_clues_iter = SERVARR_CONTEXT_CLUES.iter();
let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); assert_some_eq_x!(
servarr_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.next_servarr); &(DEFAULT_KEYBINDINGS.up, "scroll up")
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.next_servarr.desc); );
assert_some_eq_x!(
let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.down, "scroll down")
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.previous_servarr); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.previous_servarr.desc); assert_some_eq_x!(
servarr_context_clues_iter.next(),
let (key_binding, description) = servarr_context_clues_iter.next().unwrap(); &(DEFAULT_KEYBINDINGS.left, "previous tab")
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.quit); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.quit.desc); servarr_context_clues_iter.next(),
assert_eq!(servarr_context_clues_iter.next(), None); &(DEFAULT_KEYBINDINGS.right, "next tab")
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.pg_up, DEFAULT_KEYBINDINGS.pg_up.desc)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.pg_down,
DEFAULT_KEYBINDINGS.pg_down.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.next_servarr,
DEFAULT_KEYBINDINGS.next_servarr.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.previous_servarr,
DEFAULT_KEYBINDINGS.previous_servarr.desc
)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.quit, DEFAULT_KEYBINDINGS.quit.desc)
);
assert_some_eq_x!(
servarr_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.help, DEFAULT_KEYBINDINGS.help.desc)
);
assert_none!(servarr_context_clues_iter.next());
} }
#[test] #[test]
fn test_bare_popup_context_clues() { fn test_bare_popup_context_clues() {
let mut bare_popup_context_clues_iter = BARE_POPUP_CONTEXT_CLUES.iter(); let mut bare_popup_context_clues_iter = BARE_POPUP_CONTEXT_CLUES.iter();
let (key_binding, description) = bare_popup_context_clues_iter.next().unwrap(); assert_some_eq_x!(
bare_popup_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); );
assert_eq!(bare_popup_context_clues_iter.next(), None); assert_none!(bare_popup_context_clues_iter.next());
} }
#[test] #[test]
fn test_downloads_context_clues() { fn test_downloads_context_clues() {
let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter(); let mut downloads_context_clues_iter = DOWNLOADS_CONTEXT_CLUES.iter();
let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); assert_some_eq_x!(
downloads_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); &(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); )
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); downloads_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
let (key_binding, description) = downloads_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); downloads_context_clues_iter.next(),
assert_str_eq!(*description, "update downloads"); &(DEFAULT_KEYBINDINGS.update, "update downloads")
assert_eq!(downloads_context_clues_iter.next(), None); );
assert_none!(downloads_context_clues_iter.next());
} }
#[test] #[test]
fn test_blocklist_context_clues() { fn test_blocklist_context_clues() {
let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter(); let mut blocklist_context_clues_iter = BLOCKLIST_CONTEXT_CLUES.iter();
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); assert_some_eq_x!(
blocklist_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); &(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); )
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); blocklist_context_clues_iter.next(),
assert_str_eq!(*description, "details"); &(DEFAULT_KEYBINDINGS.submit, "details")
);
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); assert_some_eq_x!(
blocklist_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); &(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); );
assert_some_eq_x!(
let (key_binding, description) = blocklist_context_clues_iter.next().unwrap(); blocklist_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.clear, "clear blocklist")
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.clear); );
assert_str_eq!(*description, "clear blocklist"); assert_none!(blocklist_context_clues_iter.next());
assert_eq!(blocklist_context_clues_iter.next(), None);
} }
#[test] #[test]
fn test_confirmation_prompt_context_clues() { fn test_confirmation_prompt_context_clues() {
let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter(); let mut confirmation_prompt_context_clues_iter = CONFIRMATION_PROMPT_CONTEXT_CLUES.iter();
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); assert_some_eq_x!(
confirmation_prompt_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.confirm); &(DEFAULT_KEYBINDINGS.confirm, "submit")
assert_str_eq!(*description, "submit"); );
assert_some_eq_x!(
let (key_binding, description) = confirmation_prompt_context_clues_iter.next().unwrap(); confirmation_prompt_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel")
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); );
assert_str_eq!(*description, "cancel"); assert_none!(confirmation_prompt_context_clues_iter.next());
assert_eq!(confirmation_prompt_context_clues_iter.next(), None);
} }
#[test] #[test]
fn test_root_folders_context_clues() { fn test_root_folders_context_clues() {
let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter(); let mut root_folders_context_clues_iter = ROOT_FOLDERS_CONTEXT_CLUES.iter();
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); assert_some_eq_x!(
root_folders_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add); &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc); );
assert_some_eq_x!(
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); root_folders_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); assert_some_eq_x!(
root_folders_context_clues_iter.next(),
let (key_binding, description) = root_folders_context_clues_iter.next().unwrap(); &(
DEFAULT_KEYBINDINGS.refresh,
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); DEFAULT_KEYBINDINGS.refresh.desc
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); )
assert_eq!(root_folders_context_clues_iter.next(), None); );
assert_none!(root_folders_context_clues_iter.next());
} }
#[test] #[test]
fn test_indexers_context_clues() { fn test_indexers_context_clues() {
let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter(); let mut indexers_context_clues_iter = INDEXERS_CONTEXT_CLUES.iter();
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "edit indexer")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.settings,
DEFAULT_KEYBINDINGS.settings.desc
)
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.test, "test indexer")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.test_all, "test all indexers")
);
assert_some_eq_x!(
indexers_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_none!(indexers_context_clues_iter.next());
}
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); #[test]
assert_str_eq!(*description, "edit indexer"); fn test_history_context_clues() {
let mut history_context_clues_iter = HISTORY_CONTEXT_CLUES.iter();
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); assert_some_eq_x!(
history_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.settings); &(DEFAULT_KEYBINDINGS.submit, "details")
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.settings.desc); );
assert_some_eq_x!(
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); assert_some_eq_x!(
history_context_clues_iter.next(),
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test); assert_some_eq_x!(
assert_str_eq!(*description, "test indexer"); history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.test_all); history_context_clues_iter.next(),
assert_str_eq!(*description, "test all indexers"); &(
DEFAULT_KEYBINDINGS.refresh,
let (key_binding, description) = indexers_context_clues_iter.next().unwrap(); DEFAULT_KEYBINDINGS.refresh.desc
)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); assert_some_eq_x!(
assert_eq!(indexers_context_clues_iter.next(), None); history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(history_context_clues_iter.next());
} }
#[test] #[test]
fn test_system_context_clues() { fn test_system_context_clues() {
let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter(); let mut system_context_clues_iter = SYSTEM_CONTEXT_CLUES.iter();
let (key_binding, description) = system_context_clues_iter.next().unwrap(); assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.tasks, "open tasks")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.events, "open events")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.logs, "open logs")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "open updates")
);
assert_some_eq_x!(
system_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_none!(system_context_clues_iter.next());
}
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.tasks); #[test]
assert_str_eq!(*description, "open tasks"); fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
let (key_binding, description) = system_context_clues_iter.next().unwrap(); assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "start task")
);
assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(system_tasks_context_clues_iter.next());
}
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.events); #[test]
assert_str_eq!(*description, "open events"); fn test_servarr_context_clue_provider_delegates_to_radarr_provider() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
let (key_binding, description) = system_context_clues_iter.next().unwrap(); let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.logs); assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,);
assert_str_eq!(*description, "open logs"); }
let (key_binding, description) = system_context_clues_iter.next().unwrap(); #[test]
fn test_servarr_context_clue_provider_delegates_to_sonarr_provider() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveSonarrBlock::SystemTasks.into());
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_str_eq!(*description, "open updates");
let (key_binding, description) = system_context_clues_iter.next().unwrap(); assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,);
}
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); #[test]
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); fn test_servarr_context_clue_provider_delegates_to_readarr_provider() {
assert_eq!(system_context_clues_iter.next(), None); let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into());
let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES,);
}
#[test]
fn test_servarr_context_clue_provider_unsupported_route_returns_none() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveKeybindingBlock::Help.into());
let context_clues = ServarrContextClueProvider::get_context_clues(&mut app);
assert_none!(context_clues);
} }
} }
+85 -1
View File
@@ -14,6 +14,8 @@ generate_keybindings! {
down, down,
left, left,
right, right,
pg_down,
pg_up,
backspace, backspace,
next_servarr, next_servarr,
previous_servarr, previous_servarr,
@@ -21,9 +23,11 @@ generate_keybindings! {
search, search,
auto_search, auto_search,
settings, settings,
help,
filter, filter,
sort, sort,
edit, edit,
view,
logs, logs,
tasks, tasks,
test, test,
@@ -44,128 +48,208 @@ generate_keybindings! {
#[derive(Clone, Copy, Eq, PartialEq, Debug)] #[derive(Clone, Copy, Eq, PartialEq, Debug)]
pub struct KeyBinding { pub struct KeyBinding {
pub key: Key, pub key: Key,
pub alt: Option<Key>,
pub desc: &'static str, pub desc: &'static str,
} }
pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings { pub const DEFAULT_KEYBINDINGS: KeyBindings = KeyBindings {
add: KeyBinding { add: KeyBinding {
key: Key::Char('a'), key: Key::Char('a'),
alt: None,
desc: "add", desc: "add",
}, },
up: KeyBinding { up: KeyBinding {
key: Key::Up, key: Key::Up,
alt: Some(Key::Char('k')),
desc: "up", desc: "up",
}, },
down: KeyBinding { down: KeyBinding {
key: Key::Down, key: Key::Down,
alt: Some(Key::Char('j')),
desc: "down", desc: "down",
}, },
left: KeyBinding { left: KeyBinding {
key: Key::Left, key: Key::Left,
alt: Some(Key::Char('h')),
desc: "left", desc: "left",
}, },
right: KeyBinding { right: KeyBinding {
key: Key::Right, key: Key::Right,
alt: Some(Key::Char('l')),
desc: "right", desc: "right",
}, },
pg_down: KeyBinding {
key: Key::PgDown,
alt: Some(Key::Ctrl('d')),
desc: "page down",
},
pg_up: KeyBinding {
key: Key::PgUp,
alt: Some(Key::Ctrl('u')),
desc: "page up",
},
backspace: KeyBinding { backspace: KeyBinding {
key: Key::Backspace, key: Key::Backspace,
alt: Some(Key::Ctrl('h')),
desc: "backspace", desc: "backspace",
}, },
next_servarr: KeyBinding { next_servarr: KeyBinding {
key: Key::Tab, key: Key::Tab,
alt: None,
desc: "next servarr", desc: "next servarr",
}, },
previous_servarr: KeyBinding { previous_servarr: KeyBinding {
key: Key::BackTab, key: Key::BackTab,
alt: None,
desc: "previous servarr", desc: "previous servarr",
}, },
clear: KeyBinding { clear: KeyBinding {
key: Key::Char('c'), key: Key::Char('c'),
alt: None,
desc: "clear", desc: "clear",
}, },
auto_search: KeyBinding { auto_search: KeyBinding {
key: Key::Char('S'), key: Key::Char('S'),
alt: None,
desc: "auto search", desc: "auto search",
}, },
search: KeyBinding { search: KeyBinding {
key: Key::Char('s'), key: Key::Char('s'),
alt: None,
desc: "search", desc: "search",
}, },
settings: KeyBinding { settings: KeyBinding {
key: Key::Char('S'), key: Key::Char('S'),
alt: None,
desc: "settings", desc: "settings",
}, },
help: KeyBinding {
key: Key::Char('?'),
alt: None,
desc: "show/hide keybindings",
},
filter: KeyBinding { filter: KeyBinding {
key: Key::Char('f'), key: Key::Char('f'),
alt: None,
desc: "filter", desc: "filter",
}, },
sort: KeyBinding { sort: KeyBinding {
key: Key::Char('o'), key: Key::Char('o'),
alt: None,
desc: "sort", desc: "sort",
}, },
edit: KeyBinding { edit: KeyBinding {
key: Key::Char('e'), key: Key::Char('e'),
alt: None,
desc: "edit", desc: "edit",
}, },
view: KeyBinding {
key: Key::Char('v'),
alt: None,
desc: "view",
},
events: KeyBinding { events: KeyBinding {
key: Key::Char('e'), key: Key::Char('e'),
alt: None,
desc: "events", desc: "events",
}, },
logs: KeyBinding { logs: KeyBinding {
key: Key::Char('l'), key: Key::Char('L'),
alt: None,
desc: "logs", desc: "logs",
}, },
tasks: KeyBinding { tasks: KeyBinding {
key: Key::Char('t'), key: Key::Char('t'),
alt: None,
desc: "tasks", desc: "tasks",
}, },
test: KeyBinding { test: KeyBinding {
key: Key::Char('t'), key: Key::Char('t'),
alt: None,
desc: "test", desc: "test",
}, },
test_all: KeyBinding { test_all: KeyBinding {
key: Key::Char('T'), key: Key::Char('T'),
alt: None,
desc: "test all", desc: "test all",
}, },
toggle_monitoring: KeyBinding { toggle_monitoring: KeyBinding {
key: Key::Char('m'), key: Key::Char('m'),
alt: None,
desc: "toggle monitoring", desc: "toggle monitoring",
}, },
refresh: KeyBinding { refresh: KeyBinding {
key: Key::Ctrl('r'), key: Key::Ctrl('r'),
alt: None,
desc: "refresh", desc: "refresh",
}, },
update: KeyBinding { update: KeyBinding {
key: Key::Char('u'), key: Key::Char('u'),
alt: None,
desc: "update", desc: "update",
}, },
home: KeyBinding { home: KeyBinding {
key: Key::Home, key: Key::Home,
alt: None,
desc: "home", desc: "home",
}, },
end: KeyBinding { end: KeyBinding {
key: Key::End, key: Key::End,
alt: None,
desc: "end", desc: "end",
}, },
delete: KeyBinding { delete: KeyBinding {
key: Key::Delete, key: Key::Delete,
alt: None,
desc: "delete", desc: "delete",
}, },
submit: KeyBinding { submit: KeyBinding {
key: Key::Enter, key: Key::Enter,
alt: None,
desc: "submit", desc: "submit",
}, },
confirm: KeyBinding { confirm: KeyBinding {
key: Key::Ctrl('s'), key: Key::Ctrl('s'),
alt: None,
desc: "submit", desc: "submit",
}, },
quit: KeyBinding { quit: KeyBinding {
key: Key::Char('q'), key: Key::Char('q'),
alt: None,
desc: "quit", desc: "quit",
}, },
esc: KeyBinding { esc: KeyBinding {
key: Key::Esc, key: Key::Esc,
alt: None,
desc: "close", desc: "close",
}, },
}; };
#[macro_export]
macro_rules! matches_key {
($binding:ident, $key:expr) => {
$crate::app::key_binding::DEFAULT_KEYBINDINGS.$binding.key == $key
|| ($crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.is_some()
&& $crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.unwrap()
== $key)
};
($binding:ident, $key:expr, $ignore_special_keys:expr) => {
$crate::app::key_binding::DEFAULT_KEYBINDINGS.$binding.key == $key
|| !$ignore_special_keys
&& ($crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.is_some()
&& $crate::app::key_binding::DEFAULT_KEYBINDINGS
.$binding
.alt
.unwrap()
== $key)
};
}
+76 -31
View File
@@ -3,46 +3,91 @@ mod test {
use pretty_assertions::{assert_eq, assert_str_eq}; use pretty_assertions::{assert_eq, assert_str_eq};
use rstest::rstest; use rstest::rstest;
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS}; use crate::app::key_binding::{DEFAULT_KEYBINDINGS, KeyBinding};
use crate::event::Key; use crate::event::Key;
use crate::matches_key;
#[rstest] #[rstest]
#[case(DEFAULT_KEYBINDINGS.add, Key::Char('a'), "add")] #[case(DEFAULT_KEYBINDINGS.add, Key::Char('a'), None, "add")]
#[case(DEFAULT_KEYBINDINGS.up, Key::Up, "up")] #[case(DEFAULT_KEYBINDINGS.up, Key::Up, Some(Key::Char('k')), "up")]
#[case(DEFAULT_KEYBINDINGS.down, Key::Down, "down")] #[case(DEFAULT_KEYBINDINGS.down, Key::Down, Some(Key::Char('j')), "down")]
#[case(DEFAULT_KEYBINDINGS.left, Key::Left, "left")] #[case(DEFAULT_KEYBINDINGS.left, Key::Left, Some(Key::Char('h')), "left")]
#[case(DEFAULT_KEYBINDINGS.right, Key::Right, "right")] #[case(DEFAULT_KEYBINDINGS.right, Key::Right, Some(Key::Char('l')), "right")]
#[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, "backspace")] #[case(DEFAULT_KEYBINDINGS.pg_down, Key::PgDown, Some(Key::Ctrl('d')), "page down")]
#[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, "next servarr")] #[case(DEFAULT_KEYBINDINGS.pg_up, Key::PgUp, Some(Key::Ctrl('u')), "page up")]
#[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, "previous servarr")] #[case(DEFAULT_KEYBINDINGS.backspace, Key::Backspace, Some(Key::Ctrl('h')), "backspace")]
#[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), "clear")] #[case(DEFAULT_KEYBINDINGS.next_servarr, Key::Tab, None, "next servarr")]
#[case(DEFAULT_KEYBINDINGS.auto_search, Key::Char('S'), "auto search")] #[case(DEFAULT_KEYBINDINGS.previous_servarr, Key::BackTab, None, "previous servarr")]
#[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), "search")] #[case(DEFAULT_KEYBINDINGS.clear, Key::Char('c'), None, "clear")]
#[case(DEFAULT_KEYBINDINGS.settings, Key::Char('S'), "settings")] #[case(DEFAULT_KEYBINDINGS.auto_search, Key::Char('S'), None, "auto search")]
#[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), "filter")] #[case(DEFAULT_KEYBINDINGS.search, Key::Char('s'), None, "search")]
#[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), "sort")] #[case(DEFAULT_KEYBINDINGS.settings, Key::Char('S'), None, "settings")]
#[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), "edit")] #[case(DEFAULT_KEYBINDINGS.help, Key::Char('?'), None, "show/hide keybindings")]
#[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), "events")] #[case(DEFAULT_KEYBINDINGS.filter, Key::Char('f'), None, "filter")]
#[case(DEFAULT_KEYBINDINGS.logs, Key::Char('l'), "logs")] #[case(DEFAULT_KEYBINDINGS.sort, Key::Char('o'), None, "sort")]
#[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), "tasks")] #[case(DEFAULT_KEYBINDINGS.edit, Key::Char('e'), None, "edit")]
#[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), "test")] #[case(DEFAULT_KEYBINDINGS.view, Key::Char('v'), None, "view")]
#[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), "test all")] #[case(DEFAULT_KEYBINDINGS.events, Key::Char('e'), None, "events")]
#[case(DEFAULT_KEYBINDINGS.toggle_monitoring, Key::Char('m'), "toggle monitoring")] #[case(DEFAULT_KEYBINDINGS.logs, Key::Char('L'), None, "logs")]
#[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), "refresh")] #[case(DEFAULT_KEYBINDINGS.tasks, Key::Char('t'), None, "tasks")]
#[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), "update")] #[case(DEFAULT_KEYBINDINGS.test, Key::Char('t'), None, "test")]
#[case(DEFAULT_KEYBINDINGS.home, Key::Home, "home")] #[case(DEFAULT_KEYBINDINGS.test_all, Key::Char('T'), None, "test all")]
#[case(DEFAULT_KEYBINDINGS.end, Key::End, "end")] #[case(DEFAULT_KEYBINDINGS.toggle_monitoring, Key::Char('m'), None, "toggle monitoring")]
#[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, "delete")] #[case(DEFAULT_KEYBINDINGS.refresh, Key::Ctrl('r'), None, "refresh")]
#[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, "submit")] #[case(DEFAULT_KEYBINDINGS.update, Key::Char('u'), None, "update")]
#[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), "submit")] #[case(DEFAULT_KEYBINDINGS.home, Key::Home, None, "home")]
#[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), "quit")] #[case(DEFAULT_KEYBINDINGS.end, Key::End, None, "end")]
#[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, "close")] #[case(DEFAULT_KEYBINDINGS.delete, Key::Delete, None, "delete")]
#[case(DEFAULT_KEYBINDINGS.submit, Key::Enter, None, "submit")]
#[case(DEFAULT_KEYBINDINGS.confirm, Key::Ctrl('s'), None, "submit")]
#[case(DEFAULT_KEYBINDINGS.quit, Key::Char('q'), None, "quit")]
#[case(DEFAULT_KEYBINDINGS.esc, Key::Esc, None, "close")]
fn test_default_key_bindings_and_descriptions( fn test_default_key_bindings_and_descriptions(
#[case] key_binding: KeyBinding, #[case] key_binding: KeyBinding,
#[case] expected_key: Key, #[case] expected_key: Key,
#[case] expected_alt_key: Option<Key>,
#[case] expected_desc: &str, #[case] expected_desc: &str,
) { ) {
assert_eq!(key_binding.key, expected_key); assert_eq!(key_binding.key, expected_key);
assert_eq!(key_binding.alt, expected_alt_key);
assert_str_eq!(key_binding.desc, expected_desc); assert_str_eq!(key_binding.desc, expected_desc);
} }
#[test]
fn test_matches_key_macro() {
let key = Key::Char('t');
assert!(matches_key!(test, key));
assert!(!matches_key!(test, Key::Char('T')));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key));
assert!(matches_key!(up, alt_key));
assert!(!matches_key!(up, Key::Char('t')));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding_uses_alt_key_when_ignore_special_keys_is_false() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key, false));
assert!(matches_key!(up, alt_key, false));
assert!(!matches_key!(up, Key::Char('t'), false));
}
#[test]
fn test_matches_key_macro_with_alt_keybinding_ignores_alt_key_when_ignore_special_keys_is_true() {
let alt_key = Key::Char('k');
let key = Key::Up;
assert!(matches_key!(up, key, true));
assert!(!matches_key!(up, alt_key, true));
assert!(!matches_key!(up, Key::Char('t'), true));
}
} }
+242
View File
@@ -0,0 +1,242 @@
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ARTIST_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, ALBUM_DETAILS_BLOCKS, ARTIST_DETAILS_BLOCKS,
ARTIST_OVERVIEW_BLOCKS, ActiveLidarrBlock, DELETE_ALBUM_BLOCKS, DELETE_ARTIST_BLOCKS,
EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS, INDEXER_SETTINGS_BLOCKS, TRACK_DETAILS_BLOCKS,
};
#[cfg(test)]
#[path = "lidarr_context_clues_tests.rs"]
mod lidarr_context_clues_tests;
pub static ARTISTS_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.update, "update all"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub static ARTIST_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.delete, "delete album"),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle album monitoring",
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.view, "view overview"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static ARTIST_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static ARTIST_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_ARTIST_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit artist"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static ALBUM_DETAILS_CONTEXT_CLUES: [ContextClue; 6] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
(DEFAULT_KEYBINDINGS.submit, "track details"),
(DEFAULT_KEYBINDINGS.delete, "delete track"),
];
pub static ALBUM_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_ALBUM_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static TRACK_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static TRACK_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub(in crate::app) struct LidarrContextClueProvider;
impl ContextClueProvider for LidarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Lidarr(active_lidarr_block, _context_option) = app.get_current_route() else {
panic!("LidarrContextClueProvider::get_context_clues called with non-Lidarr route");
};
match active_lidarr_block {
_ if ARTIST_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.artist_info_tabs
.get_active_route_contextual_help(),
_ if ARTIST_OVERVIEW_BLOCKS.contains(&active_lidarr_block) => {
Some(&ARTIST_OVERVIEW_CONTEXT_CLUES)
}
_ if ALBUM_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.album_details_tabs
.get_active_route_contextual_help(),
_ if TRACK_DETAILS_BLOCKS.contains(&active_lidarr_block) => app
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.track_details_modal
.as_ref()
.expect("track_details_modal is empty")
.track_details_tabs
.get_active_route_contextual_help(),
ActiveLidarrBlock::AddArtistSearchInput
| ActiveLidarrBlock::AddArtistEmptySearchResults
| ActiveLidarrBlock::TestAllIndexers
| ActiveLidarrBlock::SystemLogs
| ActiveLidarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if EDIT_ARTIST_BLOCKS.contains(&active_lidarr_block)
|| EDIT_INDEXER_BLOCKS.contains(&active_lidarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_lidarr_block)
|| ADD_ROOT_FOLDER_BLOCKS.contains(&active_lidarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
_ if DELETE_ARTIST_BLOCKS.contains(&active_lidarr_block)
|| DELETE_ALBUM_BLOCKS.contains(&active_lidarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveLidarrBlock::AddArtistPrompt
| ActiveLidarrBlock::AddArtistSelectMonitor
| ActiveLidarrBlock::AddArtistSelectMonitorNewItems
| ActiveLidarrBlock::AddArtistSelectQualityProfile
| ActiveLidarrBlock::AddArtistSelectMetadataProfile
| ActiveLidarrBlock::AddArtistSelectRootFolder
| ActiveLidarrBlock::AddArtistTagsInput
| ActiveLidarrBlock::AddArtistAlreadyInLibrary => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_ARTIST_BLOCKS.contains(&active_lidarr_block) => {
Some(&ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveLidarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ => app
.data
.lidarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
@@ -0,0 +1,658 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::lidarr::lidarr_context_clues::{
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES, ALBUM_DETAILS_CONTEXT_CLUES,
ALBUM_HISTORY_CONTEXT_CLUES, ARTIST_DETAILS_CONTEXT_CLUES, ARTIST_HISTORY_CONTEXT_CLUES,
ARTIST_OVERVIEW_CONTEXT_CLUES, ARTISTS_CONTEXT_CLUES, LidarrContextClueProvider,
MANUAL_ALBUM_SEARCH_CONTEXT_CLUES, MANUAL_ARTIST_SEARCH_CONTEXT_CLUES,
TRACK_DETAILS_CONTEXT_CLUES, TRACK_HISTORY_CONTEXT_CLUES,
};
use crate::models::servarr_data::lidarr::lidarr_data::{
ADD_ROOT_FOLDER_BLOCKS, ActiveLidarrBlock, EDIT_ARTIST_BLOCKS, EDIT_INDEXER_BLOCKS,
INDEXER_SETTINGS_BLOCKS, LidarrData,
};
use crate::models::servarr_data::lidarr::modals::{AlbumDetailsModal, TrackDetailsModal};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use rstest::rstest;
#[test]
fn test_artists_context_clues() {
let mut artists_context_clues_iter = ARTISTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
artists_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(artists_context_clues_iter.next());
}
#[test]
fn test_artist_details_context_clues() {
let mut artist_details_context_clues_iter = ARTIST_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete album")
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle album monitoring",
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
)
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.view, "view overview")
);
assert_some_eq_x!(
artist_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(artist_details_context_clues_iter.next());
}
#[test]
fn test_artist_overview_context_clues() {
let mut artist_overview_context_clues_iter = ARTIST_OVERVIEW_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_overview_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artist_overview_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(artist_overview_context_clues_iter.next());
}
#[test]
fn test_add_artist_search_results_context_clues() {
let mut add_artist_search_results_context_clues_iter =
ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_artist_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_none!(add_artist_search_results_context_clues_iter.next());
}
#[test]
#[should_panic(
expected = "LidarrContextClueProvider::get_context_clues called with non-Lidarr route"
)]
fn test_lidarr_context_clue_provider_get_context_clues_non_lidarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
LidarrContextClueProvider::get_context_clues(&mut app);
}
#[test]
fn test_artist_history_context_clues() {
let mut artist_history_context_clues_iter = ARTIST_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
artist_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(artist_history_context_clues_iter.next());
}
#[test]
fn test_manual_artist_search_context_clues() {
let mut manual_artist_search_context_clues_iter = MANUAL_ARTIST_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit artist")
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_artist_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_artist_search_context_clues_iter.next());
}
#[test]
fn test_album_details_context_clues() {
let mut album_details_context_clues_iter = ALBUM_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "track details")
);
assert_some_eq_x!(
album_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete track")
);
assert_none!(album_details_context_clues_iter.next());
}
#[test]
fn test_album_history_context_clues() {
let mut album_history_context_clues_iter = ALBUM_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
album_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(album_history_context_clues_iter.next());
}
#[test]
fn test_manual_album_search_context_clues() {
let mut manual_album_search_context_clues_iter = MANUAL_ALBUM_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_album_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_album_search_context_clues_iter.next());
}
#[test]
fn test_track_details_context_clues() {
let mut track_details_context_clues_iter = TRACK_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
track_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
track_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(track_details_context_clues_iter.next());
}
#[test]
fn test_track_history_context_clues() {
let mut track_history_context_clues_iter = TRACK_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
track_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(track_history_context_clues_iter.next());
}
#[rstest]
#[case(0, ActiveLidarrBlock::ArtistDetails, &ARTIST_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::ArtistHistory, &ARTIST_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveLidarrBlock::ManualArtistSearch, &MANUAL_ARTIST_SEARCH_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_artist_info_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.lidarr_data = LidarrData::default();
app.data.lidarr_data.artist_info_tabs.set_index(index);
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveLidarrBlock::AlbumDetails, &ALBUM_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::AlbumHistory, &ALBUM_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveLidarrBlock::ManualAlbumSearch, &MANUAL_ALBUM_SEARCH_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_album_details_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal.album_details_tabs.set_index(index);
let lidarr_data = LidarrData {
album_details_modal: Some(album_details_modal),
..LidarrData::default()
};
app.data.lidarr_data = lidarr_data;
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveLidarrBlock::TrackDetails, &TRACK_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveLidarrBlock::TrackHistory, &TRACK_HISTORY_CONTEXT_CLUES)]
fn test_lidarr_context_clue_provider_track_details_tabs(
#[case] index: usize,
#[case] active_lidarr_block: ActiveLidarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut track_details_modal = TrackDetailsModal::default();
track_details_modal.track_details_tabs.set_index(index);
let album_details_modal = AlbumDetailsModal {
track_details_modal: Some(track_details_modal),
..AlbumDetailsModal::default()
};
let lidarr_data = LidarrData {
album_details_modal: Some(album_details_modal),
..LidarrData::default()
};
app.data.lidarr_data = lidarr_data;
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[test]
fn test_lidarr_context_clue_provider_artist_overview_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistOverview.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTIST_OVERVIEW_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::Artists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_artists_sort_prompt_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::ArtistsSortPrompt.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_search_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::SearchArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_filter_artists_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::FilterArtists.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ARTISTS_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_bare_popup_context_clues(
#[values(
ActiveLidarrBlock::AddArtistSearchInput,
ActiveLidarrBlock::AddArtistEmptySearchResults,
ActiveLidarrBlock::TestAllIndexers
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() {
let mut blocks = EDIT_ARTIST_BLOCKS.to_vec();
blocks.extend(ADD_ROOT_FOLDER_BLOCKS);
blocks.extend(INDEXER_SETTINGS_BLOCKS);
blocks.extend(EDIT_INDEXER_BLOCKS);
for active_lidarr_block in blocks {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
}
#[test]
fn test_lidarr_context_clue_provider_add_artist_search_results_context_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::AddArtistSearchResults.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_ARTIST_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_add_artist_blocks(
#[values(
ActiveLidarrBlock::AddArtistPrompt,
ActiveLidarrBlock::AddArtistSelectMonitor,
ActiveLidarrBlock::AddArtistSelectMonitorNewItems,
ActiveLidarrBlock::AddArtistSelectQualityProfile,
ActiveLidarrBlock::AddArtistSelectMetadataProfile,
ActiveLidarrBlock::AddArtistSelectRootFolder,
ActiveLidarrBlock::AddArtistTagsInput,
ActiveLidarrBlock::AddArtistAlreadyInLibrary
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_artist_blocks(
#[values(
ActiveLidarrBlock::DeleteArtistPrompt,
ActiveLidarrBlock::DeleteArtistConfirmPrompt,
ActiveLidarrBlock::DeleteArtistToggleAddListExclusion,
ActiveLidarrBlock::DeleteArtistToggleDeleteFile
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_lidarr_context_clue_provider_confirmation_prompt_context_clues_delete_album_blocks(
#[values(
ActiveLidarrBlock::DeleteAlbumPrompt,
ActiveLidarrBlock::DeleteAlbumConfirmPrompt,
ActiveLidarrBlock::DeleteAlbumToggleAddListExclusion,
ActiveLidarrBlock::DeleteAlbumToggleDeleteFile
)]
active_lidarr_block: ActiveLidarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_lidarr_block.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[test]
fn test_lidarr_context_clue_provider_system_tasks_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveLidarrBlock::SystemTasks.into());
let context_clues = LidarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
}
+783
View File
@@ -0,0 +1,783 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::models::lidarr_models::{Album, Artist, LidarrRelease};
use crate::models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock;
use crate::models::servarr_data::lidarr::modals::AlbumDetailsModal;
use crate::models::servarr_models::Indexer;
use crate::network::NetworkEvent;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
album, artist, track,
};
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artists() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Artists)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::ListArtists.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_lidarr_block_artist_details() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.data.lidarr_data.artists.set_items(vec![artist()]);
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ArtistDetails)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetAlbums(1).into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_blocklist_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.data.lidarr_data.artists.set_items(vec![artist()]);
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Blocklist)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetBlocklist.into());
assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_artist_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ArtistHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetArtistHistory(1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_artist_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualArtistSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDiscographyReleases(1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_artist_search_block_discography_releases_non_empty() {
let mut app = App::test_default();
app
.data
.lidarr_data
.discography_releases
.set_items(vec![LidarrRelease::default()]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualArtistSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_details_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumDetails)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTracks(1, 1).into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackFiles(1).into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAlbumHistory(1, 1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_album_history_block_no_op_when_albums_table_is_empty() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AlbumHistory)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.artists.set_items(vec![Artist {
id: 1,
..Artist::default()
}]);
app.data.lidarr_data.albums.set_items(vec![Album {
id: 1,
..Album::default()
}]);
app.data.lidarr_data.album_details_modal = Some(AlbumDetailsModal::default());
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAlbumReleases(1, 1).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block_is_loading() {
let mut app = App {
is_loading: true,
..App::test_default()
};
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_album_search_block_album_releases_non_empty() {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal
.album_releases
.set_items(vec![LidarrRelease::default()]);
app.data.lidarr_data.album_details_modal = Some(album_details_modal);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::ManualAlbumSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_downloads_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Downloads)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_lidarr_block_add_artist_search_results() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.add_artist_search = Some("test artist".into());
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AddArtistSearchResults)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::SearchNewArtist("test artist".to_owned()).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::History)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetHistory(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_root_folders_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::RootFolders)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::Indexers)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetIndexers.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_all_indexer_settings_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::AllIndexerSettingsPrompt)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetAllIndexerSettings.into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_indexer_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TestIndexer)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::TestIndexer(1).into());
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_all_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TestAllIndexers)
.await;
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::TestAllIndexers.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::System)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTasks.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQueuedEvents.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetLogs(500).into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_updates_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::SystemUpdates)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetUpdates.into());
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_track_details_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TrackDetails)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackDetails(5).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_track_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_lidarr_block(&ActiveLidarrBlock::TrackHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackHistory(1, 1, 5).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_check_for_lidarr_prompt_action_no_prompt_confirm() {
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = false;
app.check_for_lidarr_prompt_action().await;
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_lidarr_prompt_action() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.lidarr_data.prompt_confirm_action = Some(LidarrEvent::GetStatus);
app.check_for_lidarr_prompt_action().await;
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.should_refresh);
assert_eq!(app.data.lidarr_data.prompt_confirm_action, None);
}
#[tokio::test]
async fn test_lidarr_refresh_metadata() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.refresh_lidarr_metadata().await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.is_loading);
}
#[tokio::test]
async fn test_lidarr_on_tick_first_render() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_first_render = true;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetStatus.into());
assert!(app.is_loading);
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.is_first_render);
}
#[tokio::test]
async fn test_lidarr_on_tick_routing() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_lidarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = false;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert!(app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_lidarr_on_tick_should_refresh() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.should_refresh);
assert!(!app.data.lidarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_lidarr_on_tick_should_refresh_does_not_cancel_prompt_requests() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_loading = true;
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.lidarr_data.prompt_confirm);
assert!(!app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_lidarr_on_tick_network_tick_frequency() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.lidarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.tick_count = 2;
app.tick_until_poll = 2;
app.lidarr_on_tick(ActiveLidarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), LidarrEvent::GetRootFolders.into());
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
}
#[tokio::test]
async fn test_extract_add_new_artist_search_query() {
let app = App::test_default_fully_populated();
let query = app.extract_add_new_artist_search_query().await;
assert_str_eq!(query, "Test Artist");
}
#[tokio::test]
#[should_panic(expected = "Add artist search is empty")]
async fn test_extract_add_new_artist_search_query_panics_when_the_query_is_not_set() {
let app = App::test_default();
app.extract_add_new_artist_search_query().await;
}
#[tokio::test]
async fn test_extract_artist_id() {
let mut app = App::test_default();
app.data.lidarr_data.artists.set_items(vec![artist()]);
assert_eq!(app.extract_artist_id().await, 1);
}
#[tokio::test]
async fn test_extract_album_id() {
let mut app = App::test_default();
app.data.lidarr_data.albums.set_items(vec![album()]);
assert_eq!(app.extract_album_id().await, 1);
}
#[tokio::test]
async fn test_extract_track_id() {
let mut app = App::test_default();
let mut album_details_modal = AlbumDetailsModal::default();
album_details_modal.tracks.set_items(vec![track()]);
app.data.lidarr_data.album_details_modal = Some(album_details_modal);
assert_eq!(app.extract_track_id().await, 5);
}
#[tokio::test]
#[should_panic(expected = "album_details_modal is empty")]
async fn test_extract_track_id_panics_when_album_details_modal_is_not_set() {
let app = App::test_default();
app.extract_track_id().await;
}
#[tokio::test]
async fn test_extract_lidarr_indexer_id() {
let mut app = App::test_default();
app.data.lidarr_data.indexers.set_items(vec![Indexer {
id: 1,
..Indexer::default()
}]);
assert_eq!(app.extract_lidarr_indexer_id().await, 1);
}
}
+279
View File
@@ -0,0 +1,279 @@
use super::App;
use crate::{
models::servarr_data::lidarr::lidarr_data::ActiveLidarrBlock,
network::lidarr_network::LidarrEvent,
};
pub mod lidarr_context_clues;
#[cfg(test)]
#[path = "lidarr_tests.rs"]
mod lidarr_tests;
impl App<'_> {
pub(super) async fn dispatch_by_lidarr_block(&mut self, active_lidarr_block: &ActiveLidarrBlock) {
match active_lidarr_block {
ActiveLidarrBlock::Artists => {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::ListArtists.into())
.await;
}
ActiveLidarrBlock::Blocklist => {
self
.dispatch_network_event(LidarrEvent::GetBlocklist.into())
.await;
}
ActiveLidarrBlock::Downloads => {
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
}
ActiveLidarrBlock::ArtistDetails => {
self
.dispatch_network_event(LidarrEvent::GetAlbums(self.extract_artist_id().await).into())
.await;
}
ActiveLidarrBlock::ArtistHistory => {
self
.dispatch_network_event(
LidarrEvent::GetArtistHistory(self.extract_artist_id().await).into(),
)
.await;
}
ActiveLidarrBlock::ManualArtistSearch => {
if self.data.lidarr_data.discography_releases.is_empty() {
self
.dispatch_network_event(
LidarrEvent::GetDiscographyReleases(self.extract_artist_id().await).into(),
)
.await;
}
}
ActiveLidarrBlock::AlbumDetails => {
let artist_id = self.extract_artist_id().await;
let album_id = self.extract_album_id().await;
self
.dispatch_network_event(LidarrEvent::GetTracks(artist_id, album_id).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTrackFiles(album_id).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
}
ActiveLidarrBlock::AlbumHistory => {
if !self.data.lidarr_data.albums.is_empty() {
self
.dispatch_network_event(
LidarrEvent::GetAlbumHistory(
self.extract_artist_id().await,
self.extract_album_id().await,
)
.into(),
)
.await;
}
}
ActiveLidarrBlock::ManualAlbumSearch => {
match self.data.lidarr_data.album_details_modal.as_ref() {
Some(album_details_modal) if album_details_modal.album_releases.is_empty() => {
self
.dispatch_network_event(
LidarrEvent::GetAlbumReleases(
self.extract_artist_id().await,
self.extract_album_id().await,
)
.into(),
)
.await;
}
_ => (),
}
}
ActiveLidarrBlock::AddArtistSearchResults => {
self
.dispatch_network_event(
LidarrEvent::SearchNewArtist(self.extract_add_new_artist_search_query().await).into(),
)
.await;
}
ActiveLidarrBlock::History => {
self
.dispatch_network_event(LidarrEvent::GetHistory(500).into())
.await
}
ActiveLidarrBlock::RootFolders => {
self
.dispatch_network_event(LidarrEvent::GetRootFolders.into())
.await;
}
ActiveLidarrBlock::Indexers => {
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetIndexers.into())
.await;
}
ActiveLidarrBlock::AllIndexerSettingsPrompt => {
self
.dispatch_network_event(LidarrEvent::GetAllIndexerSettings.into())
.await;
}
ActiveLidarrBlock::TestIndexer => {
self
.dispatch_network_event(
LidarrEvent::TestIndexer(self.extract_lidarr_indexer_id().await).into(),
)
.await;
}
ActiveLidarrBlock::TestAllIndexers => {
self
.dispatch_network_event(LidarrEvent::TestAllIndexers.into())
.await;
}
ActiveLidarrBlock::System => {
self
.dispatch_network_event(LidarrEvent::GetTasks.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetQueuedEvents.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetLogs(500).into())
.await;
}
ActiveLidarrBlock::SystemUpdates => {
self
.dispatch_network_event(LidarrEvent::GetUpdates.into())
.await;
}
ActiveLidarrBlock::TrackDetails => {
self
.dispatch_network_event(
LidarrEvent::GetTrackDetails(self.extract_track_id().await).into(),
)
.await;
}
ActiveLidarrBlock::TrackHistory => {
let artist_id = self.extract_artist_id().await;
let album_id = self.extract_album_id().await;
let track_id = self.extract_track_id().await;
self
.dispatch_network_event(
LidarrEvent::GetTrackHistory(artist_id, album_id, track_id).into(),
)
.await;
}
_ => (),
}
self.check_for_lidarr_prompt_action().await;
self.reset_tick_count();
}
async fn extract_add_new_artist_search_query(&self) -> String {
self
.data
.lidarr_data
.add_artist_search
.as_ref()
.expect("Add artist search is empty")
.text
.clone()
}
async fn extract_artist_id(&self) -> i64 {
self.data.lidarr_data.artists.current_selection().id
}
async fn extract_album_id(&self) -> i64 {
self.data.lidarr_data.albums.current_selection().id
}
async fn extract_track_id(&self) -> i64 {
self
.data
.lidarr_data
.album_details_modal
.as_ref()
.expect("album_details_modal is empty")
.tracks
.current_selection()
.id
}
async fn extract_lidarr_indexer_id(&self) -> i64 {
self.data.lidarr_data.indexers.current_selection().id
}
async fn check_for_lidarr_prompt_action(&mut self) {
if self.data.lidarr_data.prompt_confirm {
self.data.lidarr_data.prompt_confirm = false;
if let Some(lidarr_event) = self.data.lidarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(lidarr_event.into()).await;
self.should_refresh = true;
}
}
}
pub(super) async fn lidarr_on_tick(&mut self, active_lidarr_block: ActiveLidarrBlock) {
if self.is_first_render {
self.refresh_lidarr_metadata().await;
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
self.refresh_lidarr_metadata().await;
}
if self.is_routing {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_lidarr_block(&active_lidarr_block).await;
}
}
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_lidarr_metadata().await;
}
}
async fn refresh_lidarr_metadata(&mut self) {
self
.dispatch_network_event(LidarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(LidarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(LidarrEvent::GetStatus.into())
.await;
}
}
+474 -73
View File
@@ -1,26 +1,37 @@
use std::process; use anyhow::{Error, Result, anyhow};
use anyhow::{anyhow, Error};
use colored::Colorize; use colored::Colorize;
use itertools::Itertools;
use log::{debug, error}; use log::{debug, error};
use regex::Regex;
use reqwest::header::{HeaderMap, HeaderName, HeaderValue};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::{Duration, Instant};
use std::{fs, process};
use tokio::sync::mpsc::Sender; use tokio::sync::mpsc::Sender;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use veil::Redact;
use crate::app::context_clues::{build_context_clue_string, SERVARR_CONTEXT_CLUES};
use crate::cli::Command; use crate::cli::Command;
use crate::models::servarr_data::Notification;
use crate::models::servarr_data::lidarr::lidarr_data::{ActiveLidarrBlock, LidarrData};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData}; use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, ReadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData}; use crate::models::servarr_data::sonarr::sonarr_data::{ActiveSonarrBlock, SonarrData};
use crate::models::servarr_models::KeybindingItem;
use crate::models::stateful_table::StatefulTable;
use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState}; use crate::models::{HorizontallyScrollableText, Route, TabRoute, TabState};
use crate::network::NetworkEvent; use crate::network::NetworkEvent;
#[cfg(test)] #[cfg(test)]
#[path = "app_tests.rs"]
mod app_tests; mod app_tests;
pub mod context_clues; pub mod context_clues;
pub mod key_binding; pub mod key_binding;
mod key_binding_tests; mod key_binding_tests;
pub mod lidarr;
pub mod radarr; pub mod radarr;
pub mod readarr;
pub mod sonarr; pub mod sonarr;
pub struct App<'a> { pub struct App<'a> {
@@ -29,20 +40,23 @@ pub struct App<'a> {
pub cancellation_token: CancellationToken, pub cancellation_token: CancellationToken,
pub is_first_render: bool, pub is_first_render: bool,
pub server_tabs: TabState, pub server_tabs: TabState,
pub keymapping_table: Option<StatefulTable<KeybindingItem>>,
pub error: HorizontallyScrollableText, pub error: HorizontallyScrollableText,
pub notification: Option<Notification>,
pub tick_until_poll: u64, pub tick_until_poll: u64,
pub ticks_until_scroll: u64, pub scroll_interval: Duration,
pub last_scroll: Instant,
pub tick_count: u64, pub tick_count: u64,
pub should_text_scroll: bool,
pub is_routing: bool, pub is_routing: bool,
pub is_loading: bool, pub is_loading: bool,
pub should_refresh: bool, pub should_refresh: bool,
pub should_ignore_quit_key: bool, pub ignore_special_keys_for_textbox_input: bool,
pub cli_mode: bool, pub cli_mode: bool,
pub config: AppConfig,
pub data: Data<'a>, pub data: Data<'a>,
} }
impl<'a> App<'a> { impl App<'_> {
pub fn new( pub fn new(
network_tx: Sender<NetworkEvent>, network_tx: Sender<NetworkEvent>,
config: AppConfig, config: AppConfig,
@@ -50,35 +64,112 @@ impl<'a> App<'a> {
) -> Self { ) -> Self {
let mut server_tabs = Vec::new(); let mut server_tabs = Vec::new();
if config.radarr.is_some() { if let Some(radarr_configs) = config.radarr {
server_tabs.push(TabRoute { let mut unnamed_idx = 0;
title: "Radarr", let radarr_tabs = radarr_configs.into_iter().map(|radarr_config| {
let name = if let Some(name) = radarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Radarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveRadarrBlock::Movies.into(), route: ActiveRadarrBlock::Movies.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None, contextual_help: None,
config: Some(radarr_config),
}
}); });
server_tabs.extend(radarr_tabs);
} }
if config.sonarr.is_some() { if let Some(sonarr_configs) = config.sonarr {
server_tabs.push(TabRoute { let mut unnamed_idx = 0;
title: "Sonarr", let sonarr_tabs = sonarr_configs.into_iter().map(|sonarr_config| {
let name = if let Some(name) = sonarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Sonarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveSonarrBlock::Series.into(), route: ActiveSonarrBlock::Series.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None, contextual_help: None,
}); config: Some(sonarr_config),
} }
});
server_tabs.extend(sonarr_tabs);
}
if let Some(lidarr_configs) = config.lidarr {
let mut unnamed_idx = 0;
let lidarr_tabs = lidarr_configs.into_iter().map(|lidarr_config| {
let name = if let Some(name) = lidarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Lidarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(lidarr_config),
}
});
server_tabs.extend(lidarr_tabs);
}
if let Some(readarr_configs) = config.readarr {
let mut unnamed_idx = 0;
let readarr_tabs = readarr_configs.into_iter().map(|readarr_config| {
let name = if let Some(name) = readarr_config.name.clone() {
name
} else {
unnamed_idx += 1;
format!("Readarr {unnamed_idx}")
};
TabRoute {
title: name,
route: ActiveReadarrBlock::Authors.into(),
contextual_help: None,
config: Some(readarr_config),
}
});
server_tabs.extend(readarr_tabs);
}
let weight_sorted_tabs = server_tabs
.into_iter()
.sorted_by(|tab1, tab2| {
Ord::cmp(
tab1
.config
.as_ref()
.unwrap()
.weight
.as_ref()
.unwrap_or(&1000),
tab2
.config
.as_ref()
.unwrap()
.weight
.as_ref()
.unwrap_or(&1000),
)
})
.collect();
App { App {
network_tx: Some(network_tx), network_tx: Some(network_tx),
config,
cancellation_token, cancellation_token,
server_tabs: TabState::new(server_tabs), server_tabs: TabState::new(weight_sorted_tabs),
..App::default() ..App::default()
} }
} }
@@ -90,19 +181,28 @@ impl<'a> App<'a> {
self.is_loading = true; self.is_loading = true;
} }
if let Some(network_tx) = &self.network_tx { if let Some(network_tx) = &self.network_tx
if let Err(e) = network_tx.send(action).await { && let Err(e) = network_tx.send(action).await
{
self.is_loading = false; self.is_loading = false;
error!("Failed to send event. {e:?}"); error!("Failed to send event. {e:?}");
self.handle_error(anyhow!(e)); self.handle_error(anyhow!(e));
} }
} }
}
pub fn reset_tick_count(&mut self) { pub fn reset_tick_count(&mut self) {
self.tick_count = 0; self.tick_count = 0;
} }
pub fn on_ui_scroll_tick(&mut self) {
if self.last_scroll.elapsed() >= self.scroll_interval {
self.should_text_scroll = true;
self.last_scroll = Instant::now();
} else {
self.should_text_scroll = false;
}
}
#[allow(dead_code)] #[allow(dead_code)]
pub fn reset(&mut self) { pub fn reset(&mut self) {
self.reset_tick_count(); self.reset_tick_count();
@@ -118,10 +218,15 @@ impl<'a> App<'a> {
} }
pub async fn on_tick(&mut self) { pub async fn on_tick(&mut self) {
if self.tick_count % self.tick_until_poll == 0 || self.is_routing || self.should_refresh { if self.tick_count.is_multiple_of(self.tick_until_poll)
|| self.is_routing
|| self.should_refresh
{
match self.get_current_route() { match self.get_current_route() {
Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await, Route::Radarr(active_radarr_block, _) => self.radarr_on_tick(active_radarr_block).await,
Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await, Route::Sonarr(active_sonarr_block, _) => self.sonarr_on_tick(active_sonarr_block).await,
Route::Lidarr(active_lidarr_block, _) => self.lidarr_on_tick(active_lidarr_block).await,
Route::Readarr(active_readarr_block, _) => self.readarr_on_tick(active_readarr_block).await,
_ => (), _ => (),
} }
@@ -158,90 +263,168 @@ impl<'a> App<'a> {
} }
pub fn get_current_route(&self) -> Route { pub fn get_current_route(&self) -> Route {
*self self
.navigation_stack .navigation_stack
.last() .last()
.unwrap_or(&self.server_tabs.tabs.first().unwrap().route) .copied()
.unwrap_or_else(|| self.server_tabs.get_active_route())
} }
} }
impl<'a> Default for App<'a> { impl Default for App<'_> {
fn default() -> Self { fn default() -> Self {
App { App {
navigation_stack: Vec::new(), navigation_stack: Vec::new(),
network_tx: None, network_tx: None,
cancellation_token: CancellationToken::new(), cancellation_token: CancellationToken::new(),
keymapping_table: None,
error: HorizontallyScrollableText::default(), error: HorizontallyScrollableText::default(),
notification: None,
is_first_render: true, is_first_render: true,
server_tabs: TabState::new(vec![ server_tabs: TabState::new(Vec::new()),
TabRoute {
title: "Radarr",
route: ActiveRadarrBlock::Movies.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
TabRoute {
title: "Sonarr",
route: ActiveSonarrBlock::Series.into(),
help: format!(
"<↑↓> scroll | ←→ change tab | {} ",
build_context_clue_string(&SERVARR_CONTEXT_CLUES)
),
contextual_help: None,
},
]),
tick_until_poll: 400, tick_until_poll: 400,
ticks_until_scroll: 4, scroll_interval: Duration::from_millis(100),
last_scroll: Instant::now(),
tick_count: 0, tick_count: 0,
should_text_scroll: false,
is_loading: false, is_loading: false,
is_routing: false, is_routing: false,
should_refresh: false, should_refresh: false,
should_ignore_quit_key: false, ignore_special_keys_for_textbox_input: false,
cli_mode: false, cli_mode: false,
config: AppConfig::default(),
data: Data::default(), data: Data::default(),
} }
} }
} }
#[cfg(test)]
impl App<'_> {
pub fn test_default() -> Self {
App {
server_tabs: TabState::new(vec![
TabRoute {
title: "Radarr".to_owned(),
route: ActiveRadarrBlock::Movies.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Sonarr".to_owned(),
route: ActiveSonarrBlock::Series.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Readarr".to_owned(),
route: ActiveReadarrBlock::Authors.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
}
pub fn test_default_fully_populated() -> Self {
App {
data: Data {
lidarr_data: LidarrData::test_default_fully_populated(),
radarr_data: RadarrData::test_default_fully_populated(),
readarr_data: ReadarrData::test_default_fully_populated(),
sonarr_data: SonarrData::test_default_fully_populated(),
},
server_tabs: TabState::new(vec![
TabRoute {
title: "Radarr".to_owned(),
route: ActiveRadarrBlock::Movies.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Sonarr".to_owned(),
route: ActiveSonarrBlock::Series.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Lidarr".to_owned(),
route: ActiveLidarrBlock::Artists.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
TabRoute {
title: "Readarr".to_owned(),
route: ActiveReadarrBlock::Authors.into(),
contextual_help: None,
config: Some(ServarrConfig::default()),
},
]),
..App::default()
}
}
pub fn get_navigation_stack_depth(&self) -> usize {
self.navigation_stack.len()
}
}
#[derive(Default)] #[derive(Default)]
pub struct Data<'a> { pub struct Data<'a> {
pub lidarr_data: LidarrData<'a>,
pub radarr_data: RadarrData<'a>, pub radarr_data: RadarrData<'a>,
pub readarr_data: ReadarrData<'a>,
pub sonarr_data: SonarrData<'a>, pub sonarr_data: SonarrData<'a>,
} }
#[derive(Debug, Deserialize, Serialize, Default, Clone)] #[derive(Debug, Deserialize, Serialize, Default, Clone)]
pub struct AppConfig { pub struct AppConfig {
pub radarr: Option<ServarrConfig>, pub theme: Option<String>,
pub sonarr: Option<ServarrConfig>, pub lidarr: Option<Vec<ServarrConfig>>,
pub radarr: Option<Vec<ServarrConfig>>,
pub readarr: Option<Vec<ServarrConfig>>,
pub sonarr: Option<Vec<ServarrConfig>>,
} }
impl AppConfig { impl AppConfig {
pub fn validate(&self) { pub fn validate(&self, config_path: &str) {
if self.radarr.is_none() && self.sonarr.is_none() { if self.lidarr.is_none()
log_and_print_error( && self.radarr.is_none()
"No Servarr configuration provided in the specified configuration file".to_owned(), && self.sonarr.is_none()
); && self.readarr.is_none()
{
log_and_print_error(format!(
"No Servarrs are configured in the file: {config_path}"
));
process::exit(1); process::exit(1);
} }
if let Some(radarr_config) = &self.radarr { if let Some(radarr_configs) = &self.radarr {
radarr_config.validate(); radarr_configs.iter().for_each(|config| config.validate());
} }
if let Some(sonarr_config) = &self.sonarr { if let Some(sonarr_configs) = &self.sonarr {
sonarr_config.validate(); sonarr_configs.iter().for_each(|config| config.validate());
}
if let Some(lidarr_configs) = &self.lidarr {
lidarr_configs.iter().for_each(|config| config.validate());
}
if let Some(readarr_configs) = &self.readarr {
readarr_configs.iter().for_each(|config| config.validate());
} }
} }
pub fn verify_config_present_for_cli(&self, command: &Command) { pub fn verify_config_present_for_cli(&self, command: &Command) {
let msg = |servarr: &str| { let msg = |servarr: &str| {
log_and_print_error(format!( log_and_print_error(format!(
"{} configuration missing; Unable to run any {} commands.", "{servarr} configuration missing; Unable to run any {servarr} commands."
servarr, servarr
)) ))
}; };
match command { match command {
@@ -253,18 +436,74 @@ impl AppConfig {
msg("Sonarr"); msg("Sonarr");
process::exit(1); process::exit(1);
} }
Command::Lidarr(_) if self.lidarr.is_none() => {
msg("Lidarr");
process::exit(1);
}
Command::Readarr(_) if self.readarr.is_none() => {
msg("Readarr");
process::exit(1);
}
_ => (), _ => (),
} }
} }
pub fn post_process_initialization(&mut self) {
if let Some(radarr_configs) = self.radarr.as_mut() {
for radarr_config in radarr_configs {
radarr_config.post_process_initialization();
}
}
if let Some(sonarr_configs) = self.sonarr.as_mut() {
for sonarr_config in sonarr_configs {
sonarr_config.post_process_initialization();
}
}
if let Some(lidarr_configs) = self.lidarr.as_mut() {
for lidarr_config in lidarr_configs {
lidarr_config.post_process_initialization();
}
}
if let Some(readarr_configs) = self.readarr.as_mut() {
for readarr_config in readarr_configs {
readarr_config.post_process_initialization();
}
}
}
} }
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Redact, Deserialize, Serialize, Clone, PartialEq, Eq)]
pub struct ServarrConfig { pub struct ServarrConfig {
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub name: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub host: Option<String>, pub host: Option<String>,
#[serde(default, deserialize_with = "deserialize_u16_env_var")]
pub port: Option<u16>, pub port: Option<u16>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub uri: Option<String>, pub uri: Option<String>,
pub api_token: String, #[serde(default, deserialize_with = "deserialize_u16_env_var")]
pub weight: Option<u16>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
#[redact]
pub api_token: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub api_token_file: Option<String>,
#[serde(default, deserialize_with = "deserialize_optional_env_var_bool")]
pub ssl: Option<bool>,
#[serde(default, deserialize_with = "deserialize_optional_env_var")]
pub ssl_cert_path: Option<String>, pub ssl_cert_path: Option<String>,
#[serde(
default,
deserialize_with = "deserialize_optional_env_var_header_map",
serialize_with = "serialize_header_map"
)]
pub custom_headers: Option<HeaderMap>,
#[serde(default, deserialize_with = "deserialize_optional_env_var_string_vec")]
pub monitored_storage_paths: Option<Vec<String>>,
} }
impl ServarrConfig { impl ServarrConfig {
@@ -273,22 +512,184 @@ impl ServarrConfig {
log_and_print_error("'host' or 'uri' is required for configuration".to_owned()); log_and_print_error("'host' or 'uri' is required for configuration".to_owned());
process::exit(1); process::exit(1);
} }
if self.api_token_file.is_none() && self.api_token.is_none() {
log_and_print_error(
"'api_token' or 'api_token_path' is required for configuration".to_owned(),
);
process::exit(1);
}
}
pub fn post_process_initialization(&mut self) {
if let Some(api_token_file) = self.api_token_file.as_ref() {
if !PathBuf::from(api_token_file).exists() {
log_and_print_error(format!(
"The specified {api_token_file} API token file does not exist"
));
process::exit(1);
}
let api_token = fs::read_to_string(api_token_file)
.map_err(|e| anyhow!(e))
.unwrap();
self.api_token = Some(api_token.trim().to_owned());
}
} }
} }
impl Default for ServarrConfig { impl Default for ServarrConfig {
fn default() -> Self { fn default() -> Self {
ServarrConfig { ServarrConfig {
name: None,
host: Some("localhost".to_string()), host: Some("localhost".to_string()),
port: None, port: None,
uri: None, uri: None,
api_token: "".to_string(), weight: None,
api_token: Some(String::new()),
api_token_file: None,
ssl_cert_path: None, ssl_cert_path: None,
ssl: None,
custom_headers: None,
monitored_storage_paths: None,
} }
} }
} }
pub fn log_and_print_error(error: String) { pub fn log_and_print_error(error: String) {
error!("{}", error); error!("{error}");
eprintln!("error: {}", error.red()); eprintln!("error: {}", error.red());
} }
fn serialize_header_map<S>(headers: &Option<HeaderMap>, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
if let Some(headers) = headers {
let mut map = HashMap::new();
for (name, value) in headers.iter() {
let name_str = name.as_str().to_string();
let value_str = value
.to_str()
.map_err(serde::ser::Error::custom)?
.to_string();
map.insert(name_str, value_str);
}
map.serialize(serializer)
} else {
serializer.serialize_none()
}
}
fn deserialize_optional_env_var<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
Ok(Some(interpolated))
}
None => Ok(None),
}
}
fn deserialize_optional_env_var_bool<'de, D>(deserializer: D) -> Result<Option<bool>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum StringOrBool {
Bool(bool),
String(String),
}
match StringOrBool::deserialize(deserializer)? {
StringOrBool::Bool(b) => Ok(Some(b)),
StringOrBool::String(s) => {
let val = interpolate_env_vars(&s)
.to_lowercase()
.parse()
.unwrap_or(false);
Ok(Some(val))
}
}
}
fn deserialize_optional_env_var_header_map<'de, D>(
deserializer: D,
) -> Result<Option<HeaderMap>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<HashMap<String, String>> = Option::deserialize(deserializer)?;
match opt {
Some(map) => {
let mut header_map = HeaderMap::new();
for (k, v) in map.iter() {
let name = HeaderName::from_bytes(k.as_bytes()).map_err(serde::de::Error::custom)?;
let value_str = interpolate_env_vars(v);
let value = HeaderValue::from_str(&value_str).map_err(serde::de::Error::custom)?;
header_map.insert(name, value);
}
Ok(Some(header_map))
}
None => Ok(None),
}
}
fn deserialize_optional_env_var_string_vec<'de, D>(
deserializer: D,
) -> Result<Option<Vec<String>>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<Vec<String>> = Option::deserialize(deserializer)?;
match opt {
Some(vec) => Ok(Some(
vec
.into_iter()
.map(|it| interpolate_env_vars(&it))
.collect(),
)),
None => Ok(None),
}
}
fn deserialize_u16_env_var<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s: Option<String> = Option::deserialize(deserializer)?;
match s {
Some(value) => {
let interpolated = interpolate_env_vars(&value);
interpolated
.parse::<u16>()
.map(Some)
.map_err(serde::de::Error::custom)
}
None => Ok(None),
}
}
fn interpolate_env_vars(s: &str) -> String {
let result = s.to_string();
let scrubbing_regex = Regex::new(r#"[\s{}!$^()\[\]\\|`'"]+"#).unwrap();
let var_regex = Regex::new(r"\$\{(.*?)}").unwrap();
var_regex
.replace_all(s, |caps: &regex::Captures<'_>| {
if let Some(mat) = caps.get(1)
&& let Ok(value) = std::env::var(mat.as_str())
{
return scrubbing_regex.replace_all(&value, "").to_string();
}
scrubbing_regex.replace_all(&result, "").to_string()
})
.to_string()
}
+14 -18
View File
@@ -8,7 +8,7 @@ pub mod radarr_context_clues;
#[path = "radarr_tests.rs"] #[path = "radarr_tests.rs"]
mod radarr_tests; mod radarr_tests;
impl<'a> App<'a> { impl App<'_> {
pub(super) async fn dispatch_by_radarr_block(&mut self, active_radarr_block: &ActiveRadarrBlock) { pub(super) async fn dispatch_by_radarr_block(&mut self, active_radarr_block: &ActiveRadarrBlock) {
match active_radarr_block { match active_radarr_block {
ActiveRadarrBlock::Blocklist => { ActiveRadarrBlock::Blocklist => {
@@ -40,7 +40,7 @@ impl<'a> App<'a> {
} }
ActiveRadarrBlock::Downloads => { ActiveRadarrBlock::Downloads => {
self self
.dispatch_network_event(RadarrEvent::GetDownloads.into()) .dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await; .await;
} }
ActiveRadarrBlock::RootFolders => { ActiveRadarrBlock::RootFolders => {
@@ -59,7 +59,12 @@ impl<'a> App<'a> {
.dispatch_network_event(RadarrEvent::GetMovies.into()) .dispatch_network_event(RadarrEvent::GetMovies.into())
.await; .await;
self self
.dispatch_network_event(RadarrEvent::GetDownloads.into()) .dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await;
}
ActiveRadarrBlock::History => {
self
.dispatch_network_event(RadarrEvent::GetHistory(500).into())
.await; .await;
} }
ActiveRadarrBlock::Indexers => { ActiveRadarrBlock::Indexers => {
@@ -157,12 +162,9 @@ impl<'a> App<'a> {
async fn check_for_radarr_prompt_action(&mut self) { async fn check_for_radarr_prompt_action(&mut self) {
if self.data.radarr_data.prompt_confirm { if self.data.radarr_data.prompt_confirm {
self.data.radarr_data.prompt_confirm = false; self.data.radarr_data.prompt_confirm = false;
if let Some(radarr_event) = &self.data.radarr_data.prompt_confirm_action { if let Some(radarr_event) = self.data.radarr_data.prompt_confirm_action.take() {
self self.dispatch_network_event(radarr_event.into()).await;
.dispatch_network_event(radarr_event.clone().into())
.await;
self.should_refresh = true; self.should_refresh = true;
self.data.radarr_data.prompt_confirm_action = None;
} }
} }
} }
@@ -188,7 +190,7 @@ impl<'a> App<'a> {
} }
} }
if self.tick_count % self.tick_until_poll == 0 { if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_radarr_metadata().await; self.refresh_radarr_metadata().await;
} }
} }
@@ -204,7 +206,7 @@ impl<'a> App<'a> {
.dispatch_network_event(RadarrEvent::GetRootFolders.into()) .dispatch_network_event(RadarrEvent::GetRootFolders.into())
.await; .await;
self self
.dispatch_network_event(RadarrEvent::GetDownloads.into()) .dispatch_network_event(RadarrEvent::GetDownloads(500).into())
.await; .await;
self self
.dispatch_network_event(RadarrEvent::GetDiskSpace.into()) .dispatch_network_event(RadarrEvent::GetDiskSpace.into())
@@ -231,7 +233,7 @@ impl<'a> App<'a> {
} }
async fn extract_movie_id(&self) -> i64 { async fn extract_movie_id(&self) -> i64 {
self.data.radarr_data.movies.current_selection().clone().id self.data.radarr_data.movies.current_selection().id
} }
async fn extract_movie_search_query(&self) -> String { async fn extract_movie_search_query(&self) -> String {
@@ -246,12 +248,6 @@ impl<'a> App<'a> {
} }
async fn extract_radarr_indexer_id(&self) -> i64 { async fn extract_radarr_indexer_id(&self) -> i64 {
self self.data.radarr_data.indexers.current_selection().id
.data
.radarr_data
.indexers
.current_selection()
.clone()
.id
} }
} }
+71 -11
View File
@@ -1,13 +1,26 @@
use crate::app::context_clues::ContextClue; use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::radarr::radarr_data::{
ADD_MOVIE_BLOCKS, ActiveRadarrBlock, DELETE_MOVIE_BLOCKS, EDIT_COLLECTION_BLOCKS,
EDIT_INDEXER_BLOCKS, EDIT_MOVIE_BLOCKS, INDEXER_SETTINGS_BLOCKS, MOVIE_DETAILS_BLOCKS,
};
#[cfg(test)] #[cfg(test)]
#[path = "radarr_context_clues_tests.rs"] #[path = "radarr_context_clues_tests.rs"]
mod radarr_context_clues_tests; mod radarr_context_clues_tests;
pub static LIBRARY_CONTEXT_CLUES: [ContextClue; 10] = [ pub static LIBRARY_CONTEXT_CLUES: [ContextClue; 11] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
@@ -49,7 +62,7 @@ pub static MOVIE_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 6] = [ pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -61,24 +74,71 @@ pub static MANUAL_MOVIE_SEARCH_CONTEXT_CLUES: [ContextClue; 6] = [
DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] =
[(DEFAULT_KEYBINDINGS.submit, "details")];
pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [ pub static ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"), (DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"), (DEFAULT_KEYBINDINGS.esc, "edit search"),
]; ];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "start task"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ pub static COLLECTION_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [
(DEFAULT_KEYBINDINGS.submit, "show overview/add movie"), (DEFAULT_KEYBINDINGS.submit, "show overview/add movie"),
(DEFAULT_KEYBINDINGS.edit, "edit collection"), (DEFAULT_KEYBINDINGS.edit, "edit collection"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub(in crate::app) struct RadarrContextClueProvider;
impl ContextClueProvider for RadarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Radarr(active_radarr_block, context_option) = app.get_current_route() else {
panic!("RadarrContextClueProvider::get_context_clues called with non-Radarr route");
};
match active_radarr_block {
_ if MOVIE_DETAILS_BLOCKS.contains(&active_radarr_block) => app
.data
.radarr_data
.movie_info_tabs
.get_active_route_contextual_help(),
ActiveRadarrBlock::TestAllIndexers
| ActiveRadarrBlock::AddMovieSearchInput
| ActiveRadarrBlock::AddMovieEmptySearchResults
| ActiveRadarrBlock::SystemLogs
| ActiveRadarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if context_option.unwrap_or(active_radarr_block)
== ActiveRadarrBlock::ViewMovieOverview =>
{
Some(&BARE_POPUP_CONTEXT_CLUES)
}
ActiveRadarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ if EDIT_COLLECTION_BLOCKS.contains(&active_radarr_block)
|| EDIT_INDEXER_BLOCKS.contains(&active_radarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_radarr_block)
|| EDIT_MOVIE_BLOCKS.contains(&active_radarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
_ if DELETE_MOVIE_BLOCKS.contains(&active_radarr_block) => {
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveRadarrBlock::AddMoviePrompt
| ActiveRadarrBlock::AddMovieSelectMonitor
| ActiveRadarrBlock::AddMovieSelectMinimumAvailability
| ActiveRadarrBlock::AddMovieSelectQualityProfile
| ActiveRadarrBlock::AddMovieSelectRootFolder
| ActiveRadarrBlock::AddMovieTagsInput
| ActiveRadarrBlock::SystemTaskStartConfirmPrompt => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_MOVIE_BLOCKS.contains(&active_radarr_block) => {
Some(&ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveRadarrBlock::CollectionDetails => Some(&COLLECTION_DETAILS_CONTEXT_CLUES),
_ => app
.data
.radarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
+445 -197
View File
@@ -1,114 +1,119 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use pretty_assertions::{assert_eq, assert_str_eq}; use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, BLOCKLIST_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES,
ContextClue, ContextClueProvider, DOWNLOADS_CONTEXT_CLUES, HISTORY_CONTEXT_CLUES,
INDEXERS_CONTEXT_CLUES, ROOT_FOLDERS_CONTEXT_CLUES, SYSTEM_CONTEXT_CLUES,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS; use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::radarr::radarr_context_clues::{ use crate::app::radarr::radarr_context_clues::{
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES, COLLECTION_DETAILS_CONTEXT_CLUES,
COLLECTION_DETAILS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, COLLECTIONS_CONTEXT_CLUES, LIBRARY_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES,
MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES, MANUAL_MOVIE_SEARCH_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES, RadarrContextClueProvider,
MOVIE_DETAILS_CONTEXT_CLUES, SYSTEM_TASKS_CONTEXT_CLUES,
}; };
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use pretty_assertions::assert_eq;
use rstest::rstest;
#[test] #[test]
fn test_library_context_clues() { fn test_library_context_clues() {
let mut library_context_clues_iter = LIBRARY_CONTEXT_CLUES.iter(); let mut library_context_clues_iter = LIBRARY_CONTEXT_CLUES.iter();
let (key_binding, description) = library_context_clues_iter.next().unwrap(); assert_some_eq_x!(
library_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.add); &(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.add.desc); );
assert_some_eq_x!(
let (key_binding, description) = library_context_clues_iter.next().unwrap(); library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); assert_some_eq_x!(
library_context_clues_iter.next(),
let (key_binding, description) = library_context_clues_iter.next().unwrap(); &(
DEFAULT_KEYBINDINGS.toggle_monitoring,
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); DEFAULT_KEYBINDINGS.toggle_monitoring.desc
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); )
);
let (key_binding, description) = library_context_clues_iter.next().unwrap(); assert_some_eq_x!(
library_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.delete); &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.delete.desc); );
assert_some_eq_x!(
let (key_binding, description) = library_context_clues_iter.next().unwrap(); library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); assert_some_eq_x!(
library_context_clues_iter.next(),
let (key_binding, description) = library_context_clues_iter.next().unwrap(); &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
let (key_binding, description) = library_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); library_context_clues_iter.next(),
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); &(
DEFAULT_KEYBINDINGS.refresh,
let (key_binding, description) = library_context_clues_iter.next().unwrap(); DEFAULT_KEYBINDINGS.refresh.desc
)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); );
assert_str_eq!(*description, "update all"); assert_some_eq_x!(
library_context_clues_iter.next(),
let (key_binding, description) = library_context_clues_iter.next().unwrap(); &(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); assert_some_eq_x!(
assert_str_eq!(*description, "details"); library_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
let (key_binding, description) = library_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); library_context_clues_iter.next(),
assert_str_eq!(*description, "cancel filter"); &(DEFAULT_KEYBINDINGS.esc, "cancel filter")
assert_eq!(library_context_clues_iter.next(), None); );
assert_none!(library_context_clues_iter.next());
} }
#[test] #[test]
fn test_collections_context_clues() { fn test_collections_context_clues() {
let mut collections_context_clues = COLLECTIONS_CONTEXT_CLUES.iter(); let mut collections_context_clues = COLLECTIONS_CONTEXT_CLUES.iter();
let (key_binding, description) = collections_context_clues.next().unwrap(); assert_some_eq_x!(
collections_context_clues.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.search); &(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.search.desc); );
assert_some_eq_x!(
let (key_binding, description) = collections_context_clues.next().unwrap(); collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); assert_some_eq_x!(
collections_context_clues.next(),
let (key_binding, description) = collections_context_clues.next().unwrap(); &(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc); collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
let (key_binding, description) = collections_context_clues.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.filter); collections_context_clues.next(),
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.filter.desc); &(
DEFAULT_KEYBINDINGS.refresh,
let (key_binding, description) = collections_context_clues.next().unwrap(); DEFAULT_KEYBINDINGS.refresh.desc
)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); assert_some_eq_x!(
collections_context_clues.next(),
let (key_binding, description) = collections_context_clues.next().unwrap(); &(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); assert_some_eq_x!(
assert_str_eq!(*description, "update all"); collections_context_clues.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
let (key_binding, description) = collections_context_clues.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); collections_context_clues.next(),
assert_str_eq!(*description, "details"); &(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
let (key_binding, description) = collections_context_clues.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, "cancel filter");
assert_eq!(collections_context_clues.next(), None); assert_eq!(collections_context_clues.next(), None);
} }
@@ -116,30 +121,32 @@ mod tests {
fn test_movie_details_context_clues() { fn test_movie_details_context_clues() {
let mut movie_details_context_clues_iter = MOVIE_DETAILS_CONTEXT_CLUES.iter(); let mut movie_details_context_clues_iter = MOVIE_DETAILS_CONTEXT_CLUES.iter();
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); assert_some_eq_x!(
movie_details_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); &(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); )
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc); movie_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); );
assert_some_eq_x!(
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); movie_details_context_clues_iter.next(),
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc); &(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); assert_some_eq_x!(
movie_details_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search); &(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc); DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
let (key_binding, description) = movie_details_context_clues_iter.next().unwrap(); )
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); assert_some_eq_x!(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc); movie_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(movie_details_context_clues_iter.next(), None); assert_eq!(movie_details_context_clues_iter.next(), None);
} }
@@ -147,53 +154,41 @@ mod tests {
fn test_manual_movie_search_context_clues() { fn test_manual_movie_search_context_clues() {
let mut manual_movie_search_context_clues_iter = MANUAL_MOVIE_SEARCH_CONTEXT_CLUES.iter(); let mut manual_movie_search_context_clues_iter = MANUAL_MOVIE_SEARCH_CONTEXT_CLUES.iter();
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap(); assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.refresh); &(
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.refresh.desc); DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap(); )
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.update);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.update.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.edit.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.sort);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.sort.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.auto_search);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.auto_search.desc);
let (key_binding, description) = manual_movie_search_context_clues_iter.next().unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_eq!(manual_movie_search_context_clues_iter.next(), None);
}
#[test]
fn test_manual_movie_search_contextual_context_clues() {
let mut manual_movie_search_contextual_context_clues_iter =
MANUAL_MOVIE_SEARCH_CONTEXTUAL_CONTEXT_CLUES.iter();
let (key_binding, description) = manual_movie_search_contextual_context_clues_iter
.next()
.unwrap();
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit);
assert_str_eq!(*description, "details");
assert_eq!(
manual_movie_search_contextual_context_clues_iter.next(),
None
); );
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_movie_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(manual_movie_search_context_clues_iter.next(), None);
} }
#[test] #[test]
@@ -201,15 +196,14 @@ mod tests {
let mut add_movie_search_results_context_clues_iter = let mut add_movie_search_results_context_clues_iter =
ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES.iter(); ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES.iter();
let (key_binding, description) = add_movie_search_results_context_clues_iter.next().unwrap(); assert_some_eq_x!(
add_movie_search_results_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); &(DEFAULT_KEYBINDINGS.submit, "details")
assert_str_eq!(*description, "details"); );
assert_some_eq_x!(
let (key_binding, description) = add_movie_search_results_context_clues_iter.next().unwrap(); add_movie_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); );
assert_str_eq!(*description, "edit search");
assert_eq!(add_movie_search_results_context_clues_iter.next(), None); assert_eq!(add_movie_search_results_context_clues_iter.next(), None);
} }
@@ -217,15 +211,14 @@ mod tests {
fn test_system_tasks_context_clues() { fn test_system_tasks_context_clues() {
let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter(); let mut system_tasks_context_clues_iter = SYSTEM_TASKS_CONTEXT_CLUES.iter();
let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap(); assert_some_eq_x!(
system_tasks_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); &(DEFAULT_KEYBINDINGS.submit, "start task")
assert_str_eq!(*description, "start task"); );
assert_some_eq_x!(
let (key_binding, description) = system_tasks_context_clues_iter.next().unwrap(); system_tasks_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc); );
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_eq!(system_tasks_context_clues_iter.next(), None); assert_eq!(system_tasks_context_clues_iter.next(), None);
} }
@@ -233,20 +226,275 @@ mod tests {
fn test_collection_details_context_clues() { fn test_collection_details_context_clues() {
let mut collection_details_context_clues_iter = COLLECTION_DETAILS_CONTEXT_CLUES.iter(); let mut collection_details_context_clues_iter = COLLECTION_DETAILS_CONTEXT_CLUES.iter();
let (key_binding, description) = collection_details_context_clues_iter.next().unwrap(); assert_some_eq_x!(
collection_details_context_clues_iter.next(),
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.submit); &(DEFAULT_KEYBINDINGS.submit, "show overview/add movie")
assert_str_eq!(*description, "show overview/add movie"); );
assert_some_eq_x!(
let (key_binding, description) = collection_details_context_clues_iter.next().unwrap(); collection_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit collection")
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.edit); );
assert_str_eq!(*description, "edit collection"); assert_some_eq_x!(
collection_details_context_clues_iter.next(),
let (key_binding, description) = collection_details_context_clues_iter.next().unwrap(); &(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_eq!(*key_binding, DEFAULT_KEYBINDINGS.esc);
assert_str_eq!(*description, DEFAULT_KEYBINDINGS.esc.desc);
assert_eq!(collection_details_context_clues_iter.next(), None); assert_eq!(collection_details_context_clues_iter.next(), None);
} }
#[test]
#[should_panic(
expected = "RadarrContextClueProvider::get_context_clues called with non-Radarr route"
)]
fn test_radarr_context_clue_provider_get_context_clues_non_radarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveSonarrBlock::default().into());
// This should panic because the route is not a Radarr route
RadarrContextClueProvider::get_context_clues(&mut app);
}
#[rstest]
#[case(ActiveRadarrBlock::TestAllIndexers, None)]
#[case(ActiveRadarrBlock::AddMovieSearchInput, None)]
#[case(ActiveRadarrBlock::AddMovieEmptySearchResults, None)]
#[case(ActiveRadarrBlock::SystemLogs, None)]
#[case(ActiveRadarrBlock::SystemUpdates, None)]
#[case(ActiveRadarrBlock::ViewMovieOverview, None)]
#[case(
ActiveRadarrBlock::CollectionDetails,
Some(ActiveRadarrBlock::ViewMovieOverview)
)]
fn test_radarr_context_clue_provider_bare_popup_context_clues(
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] context_option: Option<ActiveRadarrBlock>,
) {
let mut app = App::test_default();
app.push_navigation_stack((active_radarr_block, context_option).into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[rstest]
#[case(0, ActiveRadarrBlock::MovieDetails, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveRadarrBlock::MovieHistory, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(2, ActiveRadarrBlock::FileInfo, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(3, ActiveRadarrBlock::Cast, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(4, ActiveRadarrBlock::Crew, &MOVIE_DETAILS_CONTEXT_CLUES)]
#[case(5, ActiveRadarrBlock::ManualSearch, &MANUAL_MOVIE_SEARCH_CONTEXT_CLUES)]
fn test_radarr_context_clue_provider_movie_details_block_context_clues(
#[case] index: usize,
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.data.radarr_data.movie_info_tabs.set_index(index);
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues(
#[values(
ActiveRadarrBlock::AddMoviePrompt,
ActiveRadarrBlock::AddMovieSelectMonitor,
ActiveRadarrBlock::AddMovieSelectMinimumAvailability,
ActiveRadarrBlock::AddMovieSelectQualityProfile,
ActiveRadarrBlock::AddMovieSelectRootFolder,
ActiveRadarrBlock::AddMovieTagsInput,
ActiveRadarrBlock::SystemTaskStartConfirmPrompt
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_collection_blocks(
#[values(
ActiveRadarrBlock::EditCollectionPrompt,
ActiveRadarrBlock::EditCollectionConfirmPrompt,
ActiveRadarrBlock::EditCollectionRootFolderPathInput,
ActiveRadarrBlock::EditCollectionSelectMinimumAvailability,
ActiveRadarrBlock::EditCollectionSelectQualityProfile,
ActiveRadarrBlock::EditCollectionToggleSearchOnAdd,
ActiveRadarrBlock::EditCollectionToggleMonitored
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_indexer_blocks(
#[values(
ActiveRadarrBlock::EditIndexerPrompt,
ActiveRadarrBlock::EditIndexerConfirmPrompt,
ActiveRadarrBlock::EditIndexerApiKeyInput,
ActiveRadarrBlock::EditIndexerNameInput,
ActiveRadarrBlock::EditIndexerSeedRatioInput,
ActiveRadarrBlock::EditIndexerToggleEnableRss,
ActiveRadarrBlock::EditIndexerToggleEnableAutomaticSearch,
ActiveRadarrBlock::EditIndexerToggleEnableInteractiveSearch,
ActiveRadarrBlock::EditIndexerPriorityInput,
ActiveRadarrBlock::EditIndexerUrlInput,
ActiveRadarrBlock::EditIndexerTagsInput
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_indexer_settings_blocks(
#[values(
ActiveRadarrBlock::AllIndexerSettingsPrompt,
ActiveRadarrBlock::IndexerSettingsAvailabilityDelayInput,
ActiveRadarrBlock::IndexerSettingsConfirmPrompt,
ActiveRadarrBlock::IndexerSettingsMaximumSizeInput,
ActiveRadarrBlock::IndexerSettingsMinimumAgeInput,
ActiveRadarrBlock::IndexerSettingsRetentionInput,
ActiveRadarrBlock::IndexerSettingsRssSyncIntervalInput,
ActiveRadarrBlock::IndexerSettingsToggleAllowHardcodedSubs,
ActiveRadarrBlock::IndexerSettingsTogglePreferIndexerFlags,
ActiveRadarrBlock::IndexerSettingsWhitelistedSubtitleTagsInput
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_edit_movie_blocks(
#[values(
ActiveRadarrBlock::EditMoviePrompt,
ActiveRadarrBlock::EditMovieConfirmPrompt,
ActiveRadarrBlock::EditMoviePathInput,
ActiveRadarrBlock::EditMovieSelectMinimumAvailability,
ActiveRadarrBlock::EditMovieSelectQualityProfile,
ActiveRadarrBlock::EditMovieTagsInput,
ActiveRadarrBlock::EditMovieToggleMonitored
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_confirmation_prompt_context_clues_delete_movie_blocks(
#[values(
ActiveRadarrBlock::DeleteMoviePrompt,
ActiveRadarrBlock::DeleteMovieConfirmPrompt,
ActiveRadarrBlock::DeleteMovieToggleAddListExclusion,
ActiveRadarrBlock::DeleteMovieToggleDeleteFile
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_radarr_context_clue_provider_add_movie_search_results_context_clues(
#[values(
ActiveRadarrBlock::AddMovieSearchResults,
ActiveRadarrBlock::AddMovieAlreadyInLibrary
)]
active_radarr_block: ActiveRadarrBlock,
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_MOVIE_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[test]
fn test_radarr_context_clue_provider_collection_details_context_clues() {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::CollectionDetails.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &COLLECTION_DETAILS_CONTEXT_CLUES);
}
#[test]
fn test_radarr_context_clue_provider_system_tasks_context_clues() {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::SystemTasks.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
#[rstest]
#[case(0, ActiveRadarrBlock::Movies, &LIBRARY_CONTEXT_CLUES)]
#[case(1, ActiveRadarrBlock::Collections, &COLLECTIONS_CONTEXT_CLUES)]
#[case(2, ActiveRadarrBlock::Downloads, &DOWNLOADS_CONTEXT_CLUES)]
#[case(3, ActiveRadarrBlock::Blocklist, &BLOCKLIST_CONTEXT_CLUES)]
#[case(4, ActiveRadarrBlock::History, &HISTORY_CONTEXT_CLUES)]
#[case(5, ActiveRadarrBlock::RootFolders, &ROOT_FOLDERS_CONTEXT_CLUES)]
#[case(6, ActiveRadarrBlock::Indexers, &INDEXERS_CONTEXT_CLUES)]
#[case(7, ActiveRadarrBlock::System, &SYSTEM_CONTEXT_CLUES)]
fn test_radarr_context_clue_provider_radarr_blocks_context_clues(
#[case] index: usize,
#[case] active_radarr_block: ActiveRadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.data.radarr_data.main_tabs.set_index(index);
app.push_navigation_stack(active_radarr_block.into());
let context_clues = RadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
} }
+40 -22
View File
@@ -3,15 +3,16 @@ mod tests {
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::app::radarr::ActiveRadarrBlock;
use crate::app::App; use crate::app::App;
use crate::app::radarr::ActiveRadarrBlock;
use crate::models::radarr_models::{ use crate::models::radarr_models::{
AddMovieBody, AddMovieOptions, Collection, CollectionMovie, Credit, Movie, RadarrRelease, AddMovieBody, AddMovieOptions, Collection, CollectionMovie, Credit, MinimumAvailability, Movie,
MovieMonitor, RadarrRelease,
}; };
use crate::models::servarr_data::radarr::modals::MovieDetailsModal; use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::models::servarr_models::Indexer; use crate::models::servarr_models::Indexer;
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkEvent; use crate::network::NetworkEvent;
use crate::network::radarr_network::RadarrEvent;
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_blocklist_block() { async fn test_dispatch_by_blocklist_block() {
@@ -88,13 +89,13 @@ mod tests {
tmdb_id: 1234, tmdb_id: 1234,
title: "Test".to_owned(), title: "Test".to_owned(),
root_folder_path: "/nfs2".to_owned(), root_folder_path: "/nfs2".to_owned(),
minimum_availability: "announced".to_owned(), minimum_availability: MinimumAvailability::Announced,
monitored: true, monitored: true,
quality_profile_id: 2222, quality_profile_id: 2222,
tags: vec![1, 2], tags: vec![1, 2],
tag_input_string: None, tag_input_string: None,
add_options: AddMovieOptions { add_options: AddMovieOptions {
monitor: "movieOnly".to_owned(), monitor: MovieMonitor::MovieOnly,
search_for_movie: true, search_for_movie: true,
}, },
}; };
@@ -140,7 +141,24 @@ mod tests {
assert!(app.is_loading); assert!(app.is_loading);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
);
assert!(!app.data.radarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
let (mut app, mut sync_network_rx) = construct_app_unit();
app
.dispatch_by_radarr_block(&ActiveRadarrBlock::History)
.await;
assert!(app.is_loading);
assert_eq!(
sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetHistory(500).into()
); );
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -186,7 +204,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -467,7 +485,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_cast_crew_blocks_cast_and_crew_non_empty() { async fn test_dispatch_by_cast_crew_blocks_cast_and_crew_non_empty() {
let mut app = App::default(); let mut app = App::test_default();
for active_radarr_block in &[ActiveRadarrBlock::Cast, ActiveRadarrBlock::Crew] { for active_radarr_block in &[ActiveRadarrBlock::Cast, ActiveRadarrBlock::Crew] {
let mut movie_details_modal = MovieDetailsModal::default(); let mut movie_details_modal = MovieDetailsModal::default();
@@ -511,7 +529,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_manual_search_block_movie_releases_non_empty() { async fn test_dispatch_by_manual_search_block_movie_releases_non_empty() {
let mut app = App::default(); let mut app = App::test_default();
let mut movie_details_modal = MovieDetailsModal::default(); let mut movie_details_modal = MovieDetailsModal::default();
movie_details_modal movie_details_modal
.movie_releases .movie_releases
@@ -531,7 +549,7 @@ mod tests {
async fn test_dispatch_by_manual_search_block_is_loading() { async fn test_dispatch_by_manual_search_block_is_loading() {
let mut app = App { let mut app = App {
is_loading: true, is_loading: true,
..App::default() ..App::test_default()
}; };
app app
@@ -545,7 +563,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_check_for_radarr_prompt_action_no_prompt_confirm() { async fn test_check_for_radarr_prompt_action_no_prompt_confirm() {
let mut app = App::default(); let mut app = App::test_default();
app.data.radarr_data.prompt_confirm = false; app.data.radarr_data.prompt_confirm = false;
app.check_for_radarr_prompt_action().await; app.check_for_radarr_prompt_action().await;
@@ -591,7 +609,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
@@ -625,7 +643,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
@@ -650,7 +668,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
} }
@@ -675,7 +693,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert!(app.should_refresh); assert!(app.should_refresh);
assert!(!app.data.radarr_data.prompt_confirm); assert!(!app.data.radarr_data.prompt_confirm);
@@ -692,7 +710,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert!(app.is_loading); assert!(app.is_loading);
assert!(app.should_refresh); assert!(app.should_refresh);
@@ -722,14 +740,14 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
RadarrEvent::GetDownloads.into() RadarrEvent::GetDownloads(500).into()
); );
assert!(app.is_loading); assert!(app.is_loading);
} }
#[tokio::test] #[tokio::test]
async fn test_populate_movie_collection_table_unfiltered() { async fn test_populate_movie_collection_table_unfiltered() {
let mut app = App::default(); let mut app = App::test_default();
app.data.radarr_data.collections.set_items(vec![Collection { app.data.radarr_data.collections.set_items(vec![Collection {
movies: Some(vec![CollectionMovie::default()]), movies: Some(vec![CollectionMovie::default()]),
..Collection::default() ..Collection::default()
@@ -742,7 +760,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_populate_movie_collection_table_filtered() { async fn test_populate_movie_collection_table_filtered() {
let mut app = App::default(); let mut app = App::test_default();
app app
.data .data
.radarr_data .radarr_data
@@ -759,7 +777,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_extract_movie_id() { async fn test_extract_movie_id() {
let mut app = App::default(); let mut app = App::test_default();
app.data.radarr_data.movies.set_items(vec![Movie { app.data.radarr_data.movies.set_items(vec![Movie {
id: 1, id: 1,
..Movie::default() ..Movie::default()
@@ -770,7 +788,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_extract_radarr_indexer_id() { async fn test_extract_radarr_indexer_id() {
let mut app = App::default(); let mut app = App::test_default();
app.data.radarr_data.indexers.set_items(vec![Indexer { app.data.radarr_data.indexers.set_items(vec![Indexer {
id: 1, id: 1,
..Indexer::default() ..Indexer::default()
@@ -785,7 +803,7 @@ mod tests {
network_tx: Some(sync_network_tx), network_tx: Some(sync_network_tx),
tick_count: 1, tick_count: 1,
is_first_render: false, is_first_render: false,
..App::default() ..App::test_default()
}; };
app.data.radarr_data.prompt_confirm = true; app.data.radarr_data.prompt_confirm = true;
+248
View File
@@ -0,0 +1,248 @@
use super::App;
use crate::{
models::servarr_data::readarr::readarr_data::ActiveReadarrBlock,
network::readarr_network::ReadarrEvent,
};
pub mod readarr_context_clues;
#[cfg(test)]
#[path = "readarr_tests.rs"]
mod readarr_tests;
impl App<'_> {
pub(super) async fn dispatch_by_readarr_block(
&mut self,
active_readarr_block: &ActiveReadarrBlock,
) {
match active_readarr_block {
ActiveReadarrBlock::Authors => {
self
.dispatch_network_event(ReadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(ReadarrEvent::ListAuthors.into())
.await;
}
ActiveReadarrBlock::Blocklist => {
self
.dispatch_network_event(ReadarrEvent::GetBlocklist.into())
.await;
}
ActiveReadarrBlock::Downloads => {
self
.dispatch_network_event(ReadarrEvent::GetDownloads(500).into())
.await;
}
ActiveReadarrBlock::AuthorDetails => {
self
.dispatch_network_event(ReadarrEvent::GetBooks(self.extract_author_id().await).into())
.await;
}
ActiveReadarrBlock::AuthorHistory => {
self
.dispatch_network_event(
ReadarrEvent::GetAuthorHistory(self.extract_author_id().await).into(),
)
.await;
}
ActiveReadarrBlock::ManualAuthorSearch => {
if self.data.readarr_data.author_releases.is_empty() {
self
.dispatch_network_event(
ReadarrEvent::GetAuthorReleases(self.extract_author_id().await).into(),
)
.await;
}
}
ActiveReadarrBlock::BookDetails => {
let book_id = self.extract_book_id().await;
self
.dispatch_network_event(ReadarrEvent::GetBookEditions(book_id).into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetBookFiles(book_id).into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetDownloads(500).into())
.await;
}
ActiveReadarrBlock::BookHistory => {
if !self.data.readarr_data.books.is_empty() {
self
.dispatch_network_event(
ReadarrEvent::GetBookHistory(
self.extract_author_id().await,
self.extract_book_id().await,
)
.into(),
)
.await;
}
}
ActiveReadarrBlock::ManualBookSearch => {
match self.data.readarr_data.book_details_modal.as_ref() {
Some(book_details_modal) if book_details_modal.book_releases.is_empty() => {
self
.dispatch_network_event(
ReadarrEvent::GetBookReleases(self.extract_book_id().await).into(),
)
.await;
}
_ => (),
}
}
ActiveReadarrBlock::AddAuthorSearchResults => {
self
.dispatch_network_event(
ReadarrEvent::SearchNewAuthor(self.extract_add_new_author_search_query().await).into(),
)
.await;
}
ActiveReadarrBlock::History => {
self
.dispatch_network_event(ReadarrEvent::GetHistory(500).into())
.await
}
ActiveReadarrBlock::RootFolders => {
self
.dispatch_network_event(ReadarrEvent::GetRootFolders.into())
.await;
}
ActiveReadarrBlock::Indexers => {
self
.dispatch_network_event(ReadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetIndexers.into())
.await;
}
ActiveReadarrBlock::AllIndexerSettingsPrompt => {
self
.dispatch_network_event(ReadarrEvent::GetAllIndexerSettings.into())
.await;
}
ActiveReadarrBlock::TestIndexer => {
self
.dispatch_network_event(
ReadarrEvent::TestIndexer(self.extract_readarr_indexer_id().await).into(),
)
.await;
}
ActiveReadarrBlock::TestAllIndexers => {
self
.dispatch_network_event(ReadarrEvent::TestAllIndexers.into())
.await;
}
ActiveReadarrBlock::System => {
self
.dispatch_network_event(ReadarrEvent::GetTasks.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetQueuedEvents.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetLogs(500).into())
.await;
}
ActiveReadarrBlock::SystemUpdates => {
self
.dispatch_network_event(ReadarrEvent::GetUpdates.into())
.await;
}
_ => (),
}
self.check_for_readarr_prompt_action().await;
self.reset_tick_count();
}
async fn extract_add_new_author_search_query(&self) -> String {
self
.data
.readarr_data
.add_author_search
.as_ref()
.expect("Add author search is empty")
.text
.clone()
}
async fn extract_author_id(&self) -> i64 {
self.data.readarr_data.authors.current_selection().id
}
async fn extract_book_id(&self) -> i64 {
self.data.readarr_data.books.current_selection().id
}
async fn extract_readarr_indexer_id(&self) -> i64 {
self.data.readarr_data.indexers.current_selection().id
}
async fn check_for_readarr_prompt_action(&mut self) {
if self.data.readarr_data.prompt_confirm {
self.data.readarr_data.prompt_confirm = false;
if let Some(readarr_event) = self.data.readarr_data.prompt_confirm_action.take() {
self.dispatch_network_event(readarr_event.into()).await;
self.should_refresh = true;
}
}
}
pub(super) async fn readarr_on_tick(&mut self, active_readarr_block: ActiveReadarrBlock) {
if self.is_first_render {
self.refresh_readarr_metadata().await;
self.dispatch_by_readarr_block(&active_readarr_block).await;
self.is_first_render = false;
return;
}
if self.should_refresh {
self.dispatch_by_readarr_block(&active_readarr_block).await;
self.refresh_readarr_metadata().await;
}
if self.is_routing {
if !self.should_refresh {
self.cancellation_token.cancel();
} else {
self.dispatch_by_readarr_block(&active_readarr_block).await;
}
}
if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_readarr_metadata().await;
}
}
async fn refresh_readarr_metadata(&mut self) {
self
.dispatch_network_event(ReadarrEvent::GetQualityProfiles.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetMetadataProfiles.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetTags.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetRootFolders.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetDownloads(500).into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetDiskSpace.into())
.await;
self
.dispatch_network_event(ReadarrEvent::GetStatus.into())
.await;
}
}
+230
View File
@@ -0,0 +1,230 @@
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::models::Route;
use crate::models::servarr_data::readarr::readarr_data::{
ADD_AUTHOR_BLOCKS, ADD_ROOT_FOLDER_BLOCKS, AUTHOR_DETAILS_BLOCKS, AUTHOR_OVERVIEW_BLOCKS,
ActiveReadarrBlock, BOOK_DETAILS_BLOCKS, DELETE_AUTHOR_BLOCKS, DELETE_BOOK_BLOCKS,
EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS, EDITION_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS,
};
#[cfg(test)]
#[path = "readarr_context_clues_tests.rs"]
mod readarr_context_clues_tests;
pub static AUTHORS_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.update, "update all"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"),
];
pub static ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "edit search"),
];
pub static AUTHOR_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit author"),
(DEFAULT_KEYBINDINGS.delete, "delete book"),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle book monitoring",
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.view, "view overview"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static AUTHOR_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static AUTHOR_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit author"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.edit, "edit author"),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static BOOK_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
(DEFAULT_KEYBINDINGS.submit, "edition details"),
];
pub static BOOK_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
];
pub static BOOK_FILE_CONTEXT_CLUES: [ContextClue; 3] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.delete, "delete book file"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static MANUAL_BOOK_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub static EDITION_DETAILS_CONTEXT_CLUES: [ContextClue; 2] = [
(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
];
pub(in crate::app) struct ReadarrContextClueProvider;
impl ContextClueProvider for ReadarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Readarr(active_readarr_block, _context_option) = app.get_current_route() else {
panic!("ReadarrContextClueProvider::get_context_clues called with non-Readarr route");
};
match active_readarr_block {
_ if AUTHOR_DETAILS_BLOCKS.contains(&active_readarr_block) => app
.data
.readarr_data
.author_info_tabs
.get_active_route_contextual_help(),
_ if AUTHOR_OVERVIEW_BLOCKS.contains(&active_readarr_block) => {
Some(&AUTHOR_OVERVIEW_CONTEXT_CLUES)
}
_ if BOOK_DETAILS_BLOCKS.contains(&active_readarr_block) => app
.data
.readarr_data
.book_details_modal
.as_ref()
.expect("book_details_modal is empty")
.book_details_tabs
.get_active_route_contextual_help(),
_ if EDITION_DETAILS_BLOCKS.contains(&active_readarr_block) => {
Some(&EDITION_DETAILS_CONTEXT_CLUES)
}
ActiveReadarrBlock::AddAuthorSearchInput
| ActiveReadarrBlock::AddAuthorEmptySearchResults
| ActiveReadarrBlock::TestAllIndexers
| ActiveReadarrBlock::SystemLogs
| ActiveReadarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if EDIT_AUTHOR_BLOCKS.contains(&active_readarr_block)
|| EDIT_INDEXER_BLOCKS.contains(&active_readarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_readarr_block)
|| ADD_ROOT_FOLDER_BLOCKS.contains(&active_readarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
_ if DELETE_AUTHOR_BLOCKS.contains(&active_readarr_block)
|| DELETE_BOOK_BLOCKS.contains(&active_readarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveReadarrBlock::AddAuthorPrompt
| ActiveReadarrBlock::AddAuthorSelectMonitor
| ActiveReadarrBlock::AddAuthorSelectMonitorNewItems
| ActiveReadarrBlock::AddAuthorSelectQualityProfile
| ActiveReadarrBlock::AddAuthorSelectMetadataProfile
| ActiveReadarrBlock::AddAuthorSelectRootFolder
| ActiveReadarrBlock::AddAuthorTagsInput
| ActiveReadarrBlock::AddAuthorAlreadyInLibrary => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_AUTHOR_BLOCKS.contains(&active_readarr_block) => {
Some(&ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveReadarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ => app
.data
.readarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
@@ -0,0 +1,628 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClue, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::readarr::readarr_context_clues::{
ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES, AUTHOR_DETAILS_CONTEXT_CLUES,
AUTHOR_HISTORY_CONTEXT_CLUES, AUTHOR_OVERVIEW_CONTEXT_CLUES, AUTHORS_CONTEXT_CLUES,
BOOK_DETAILS_CONTEXT_CLUES, BOOK_FILE_CONTEXT_CLUES, BOOK_HISTORY_CONTEXT_CLUES,
EDITION_DETAILS_CONTEXT_CLUES, MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES,
MANUAL_BOOK_SEARCH_CONTEXT_CLUES, ReadarrContextClueProvider,
};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_data::readarr::modals::BookDetailsModal;
use crate::models::servarr_data::readarr::readarr_data::{
ADD_ROOT_FOLDER_BLOCKS, ActiveReadarrBlock, EDIT_AUTHOR_BLOCKS, EDIT_INDEXER_BLOCKS,
INDEXER_SETTINGS_BLOCKS, ReadarrData,
};
use rstest::rstest;
#[test]
fn test_authors_context_clues() {
let mut authors_context_clues_iter = AUTHORS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc
)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, "update all")
);
assert_some_eq_x!(
authors_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter")
);
assert_none!(authors_context_clues_iter.next());
}
#[test]
fn test_author_details_context_clues() {
let mut author_details_context_clues_iter = AUTHOR_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
)
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit author")
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete book")
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.toggle_monitoring,
"toggle book monitoring",
)
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
)
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.view, "view overview")
);
assert_some_eq_x!(
author_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(author_details_context_clues_iter.next());
}
#[test]
fn test_author_overview_context_clues() {
let mut author_overview_context_clues_iter = AUTHOR_OVERVIEW_CONTEXT_CLUES.iter();
assert_some_eq_x!(
author_overview_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
author_overview_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(author_overview_context_clues_iter.next());
}
#[test]
fn test_add_author_search_results_context_clues() {
let mut add_author_search_results_context_clues_iter =
ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
add_author_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
add_author_search_results_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "edit search")
);
assert_none!(add_author_search_results_context_clues_iter.next());
}
#[test]
#[should_panic(
expected = "ReadarrContextClueProvider::get_context_clues called with non-Readarr route"
)]
fn test_readarr_context_clue_provider_get_context_clues_non_readarr_route() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
ReadarrContextClueProvider::get_context_clues(&mut app);
}
#[test]
fn test_author_history_context_clues() {
let mut author_history_context_clues_iter = AUTHOR_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit author")
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
author_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(author_history_context_clues_iter.next());
}
#[test]
fn test_manual_author_search_context_clues() {
let mut manual_author_search_context_clues_iter = MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.edit, "edit author")
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.update, DEFAULT_KEYBINDINGS.update.desc)
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_author_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_author_search_context_clues_iter.next());
}
#[test]
fn test_book_details_context_clues() {
let mut book_details_context_clues_iter = BOOK_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
book_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
book_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
book_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
book_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_some_eq_x!(
book_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "edition details")
);
assert_none!(book_details_context_clues_iter.next());
}
#[test]
fn test_book_history_context_clues() {
let mut book_history_context_clues_iter = BOOK_HISTORY_CONTEXT_CLUES.iter();
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc)
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc)
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
book_history_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, "cancel filter/close")
);
assert_none!(book_history_context_clues_iter.next());
}
#[test]
fn test_book_file_context_clues() {
let mut book_file_context_clues_iter = BOOK_FILE_CONTEXT_CLUES.iter();
assert_some_eq_x!(
book_file_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
book_file_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.delete, "delete book file")
);
assert_some_eq_x!(
book_file_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(book_file_context_clues_iter.next());
}
#[test]
fn test_manual_book_search_context_clues() {
let mut manual_book_search_context_clues_iter = MANUAL_BOOK_SEARCH_CONTEXT_CLUES.iter();
assert_some_eq_x!(
manual_book_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
manual_book_search_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc
)
);
assert_some_eq_x!(
manual_book_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc)
);
assert_some_eq_x!(
manual_book_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.submit, "details")
);
assert_some_eq_x!(
manual_book_search_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(manual_book_search_context_clues_iter.next());
}
#[test]
fn test_edition_details_context_clues() {
let mut edition_details_context_clues_iter = EDITION_DETAILS_CONTEXT_CLUES.iter();
assert_some_eq_x!(
edition_details_context_clues_iter.next(),
&(
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc
)
);
assert_some_eq_x!(
edition_details_context_clues_iter.next(),
&(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc)
);
assert_none!(edition_details_context_clues_iter.next());
}
#[rstest]
#[case(0, ActiveReadarrBlock::AuthorDetails, &AUTHOR_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveReadarrBlock::AuthorHistory, &AUTHOR_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveReadarrBlock::ManualAuthorSearch, &MANUAL_AUTHOR_SEARCH_CONTEXT_CLUES)]
fn test_readarr_context_clue_provider_author_info_tabs(
#[case] index: usize,
#[case] active_readarr_block: ActiveReadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
app.data.readarr_data = ReadarrData::default();
app.data.readarr_data.author_info_tabs.set_index(index);
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[rstest]
#[case(0, ActiveReadarrBlock::BookDetails, &BOOK_DETAILS_CONTEXT_CLUES)]
#[case(1, ActiveReadarrBlock::BookHistory, &BOOK_HISTORY_CONTEXT_CLUES)]
#[case(2, ActiveReadarrBlock::BookFileInfo, &BOOK_FILE_CONTEXT_CLUES)]
#[case(3, ActiveReadarrBlock::ManualBookSearch, &MANUAL_BOOK_SEARCH_CONTEXT_CLUES)]
fn test_readarr_context_clue_provider_book_details_tabs(
#[case] index: usize,
#[case] active_readarr_block: ActiveReadarrBlock,
#[case] expected_context_clues: &[ContextClue],
) {
let mut app = App::test_default();
let mut book_details_modal = BookDetailsModal::default();
book_details_modal.book_details_tabs.set_index(index);
let readarr_data = ReadarrData {
book_details_modal: Some(book_details_modal),
..ReadarrData::default()
};
app.data.readarr_data = readarr_data;
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, expected_context_clues);
}
#[test]
fn test_readarr_context_clue_provider_book_edition_details_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::BookEditionDetails.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &EDITION_DETAILS_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_author_overview_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::AuthorOverview.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &AUTHOR_OVERVIEW_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_authors_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::Authors.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_authors_sort_prompt_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::AuthorsSortPrompt.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_search_authors_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::SearchAuthors.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_filter_authors_block() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::FilterAuthors.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &AUTHORS_CONTEXT_CLUES);
}
#[rstest]
fn test_readarr_context_clue_provider_bare_popup_context_clues(
#[values(
ActiveReadarrBlock::AddAuthorSearchInput,
ActiveReadarrBlock::AddAuthorEmptySearchResults,
ActiveReadarrBlock::TestAllIndexers,
ActiveReadarrBlock::SystemLogs,
ActiveReadarrBlock::SystemUpdates
)]
active_readarr_block: ActiveReadarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &BARE_POPUP_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_confirmation_prompt_popup_clues_edit_indexer_blocks() {
let mut blocks = EDIT_AUTHOR_BLOCKS.to_vec();
blocks.extend(ADD_ROOT_FOLDER_BLOCKS);
blocks.extend(INDEXER_SETTINGS_BLOCKS);
blocks.extend(EDIT_INDEXER_BLOCKS);
for active_readarr_block in blocks {
let mut app = App::test_default();
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
}
#[test]
fn test_readarr_context_clue_provider_add_author_search_results_context_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::AddAuthorSearchResults.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &ADD_AUTHOR_SEARCH_RESULTS_CONTEXT_CLUES);
}
#[rstest]
fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_add_author_blocks(
#[values(
ActiveReadarrBlock::AddAuthorPrompt,
ActiveReadarrBlock::AddAuthorSelectMonitor,
ActiveReadarrBlock::AddAuthorSelectMonitorNewItems,
ActiveReadarrBlock::AddAuthorSelectQualityProfile,
ActiveReadarrBlock::AddAuthorSelectMetadataProfile,
ActiveReadarrBlock::AddAuthorSelectRootFolder,
ActiveReadarrBlock::AddAuthorTagsInput,
ActiveReadarrBlock::AddAuthorAlreadyInLibrary
)]
active_readarr_block: ActiveReadarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_author_blocks(
#[values(
ActiveReadarrBlock::DeleteAuthorPrompt,
ActiveReadarrBlock::DeleteAuthorConfirmPrompt,
ActiveReadarrBlock::DeleteAuthorToggleAddListExclusion,
ActiveReadarrBlock::DeleteAuthorToggleDeleteFile
)]
active_readarr_block: ActiveReadarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[rstest]
fn test_readarr_context_clue_provider_confirmation_prompt_context_clues_delete_book_blocks(
#[values(
ActiveReadarrBlock::DeleteBookPrompt,
ActiveReadarrBlock::DeleteBookConfirmPrompt,
ActiveReadarrBlock::DeleteBookToggleAddListExclusion,
ActiveReadarrBlock::DeleteBookToggleDeleteFile
)]
active_readarr_block: ActiveReadarrBlock,
) {
let mut app = App::test_default();
app.push_navigation_stack(active_readarr_block.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &CONFIRMATION_PROMPT_CONTEXT_CLUES);
}
#[test]
fn test_readarr_context_clue_provider_system_tasks_clues() {
let mut app = App::test_default();
app.push_navigation_stack(ActiveReadarrBlock::SystemTasks.into());
let context_clues = ReadarrContextClueProvider::get_context_clues(&mut app);
assert_some_eq_x!(context_clues, &SYSTEM_TASKS_CONTEXT_CLUES);
}
}
+798
View File
@@ -0,0 +1,798 @@
#[cfg(test)]
mod tests {
use crate::app::App;
use crate::models::readarr_models::{Author, Book, ReadarrRelease};
use crate::models::servarr_data::readarr::modals::BookDetailsModal;
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::models::servarr_models::Indexer;
use crate::network::NetworkEvent;
use crate::network::readarr_network::ReadarrEvent;
use pretty_assertions::{assert_eq, assert_str_eq};
use tokio::sync::mpsc;
#[tokio::test]
async fn test_dispatch_by_readarr_block_authors() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Authors)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::ListAuthors.into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_readarr_block_author_details() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorDetails)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBooks(1).into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_blocklist_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Blocklist)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetBlocklist.into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_author_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::AuthorHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetAuthorHistory(1).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_author_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetAuthorReleases(1).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_author_search_block_author_releases_non_empty() {
let mut app = App::test_default();
app
.data
.readarr_data
.author_releases
.set_items(vec![ReadarrRelease::default()]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::ManualAuthorSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_book_details_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app.data.readarr_data.books.set_items(vec![Book {
id: 2,
..Book::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::BookDetails)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetBookEditions(2).into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetBookFiles(2).into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_book_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app.data.readarr_data.books.set_items(vec![Book {
id: 2,
..Book::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetBookHistory(1, 2).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_book_history_block_no_op_when_books_table_is_empty() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::BookHistory)
.await;
assert!(!app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_book_search_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
app.data.readarr_data.books.set_items(vec![Book {
id: 2,
..Book::default()
}]);
app.data.readarr_data.book_details_modal = Some(BookDetailsModal::default());
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetBookReleases(2).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_book_search_block_is_loading() {
let mut app = App {
is_loading: true,
..App::test_default()
};
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch)
.await;
assert!(app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_manual_book_search_block_book_releases_non_empty() {
let mut app = App::test_default();
let mut book_details_modal = BookDetailsModal::default();
book_details_modal
.book_releases
.set_items(vec![ReadarrRelease::default()]);
app.data.readarr_data.book_details_modal = Some(book_details_modal);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::ManualBookSearch)
.await;
assert!(!app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_book_edition_details_block_does_not_dispatch() {
let mut app = App::test_default_fully_populated();
app.data.readarr_data.prompt_confirm = false;
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::BookEditionDetails)
.await;
assert!(!app.is_loading);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_downloads_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Downloads)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_readarr_block_add_author_search_results() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.add_author_search = Some("test author".into());
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::AddAuthorSearchResults)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::SearchNewAuthor("test author".to_owned()).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_history_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::History)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetHistory(500).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_root_folders_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::RootFolders)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetRootFolders.into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Indexers)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into());
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetIndexers.into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_all_indexer_settings_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::AllIndexerSettingsPrompt)
.await;
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetAllIndexerSettings.into()
);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_indexer_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.indexers.set_items(vec![Indexer {
id: 3,
..Indexer::default()
}]);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::TestIndexer)
.await;
assert!(app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::TestIndexer(3).into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_test_all_indexers_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::TestAllIndexers)
.await;
assert!(app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::TestAllIndexers.into()
);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::System)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTasks.into());
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetQueuedEvents.into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetLogs(500).into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_system_updates_block() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::SystemUpdates)
.await;
assert!(app.is_loading);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetUpdates.into());
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_readarr_block_resets_the_tick_count() {
let mut app = App {
tick_count: 2,
..App::test_default()
};
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Authors)
.await;
assert_eq!(app.tick_count, 0);
}
#[tokio::test]
async fn test_dispatch_by_readarr_block_clears_the_pending_prompt_confirmation() {
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app
.dispatch_by_readarr_block(&ActiveReadarrBlock::Authors)
.await;
assert!(!app.data.readarr_data.prompt_confirm);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_readarr_prompt_action_no_prompt_confirm() {
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = false;
app.check_for_readarr_prompt_action().await;
assert!(!app.data.readarr_data.prompt_confirm);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_readarr_prompt_action_with_no_prompt_confirm_action() {
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.check_for_readarr_prompt_action().await;
assert!(!app.data.readarr_data.prompt_confirm);
assert_none!(app.data.readarr_data.prompt_confirm_action);
assert!(!app.should_refresh);
}
#[tokio::test]
async fn test_check_for_readarr_prompt_action() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.data.readarr_data.prompt_confirm_action = Some(ReadarrEvent::GetStatus);
app.check_for_readarr_prompt_action().await;
assert!(!app.data.readarr_data.prompt_confirm);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into());
assert!(app.should_refresh);
assert_eq!(app.data.readarr_data.prompt_confirm_action, None);
}
#[tokio::test]
async fn test_readarr_refresh_metadata() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.refresh_readarr_metadata().await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into());
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetRootFolders.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into());
assert!(app.is_loading);
}
#[tokio::test]
async fn test_readarr_on_tick_first_render() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_first_render = true;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into());
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetRootFolders.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetDiskSpace.into());
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetStatus.into());
assert!(app.is_loading);
assert!(!app.data.readarr_data.prompt_confirm);
assert!(!app.is_first_render);
}
#[tokio::test]
async fn test_readarr_on_tick_routing() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(!app.data.readarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_readarr_on_tick_routing_while_long_request_is_running_should_cancel_request() {
let (tx, _) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_routing = true;
app.should_refresh = false;
app.is_first_render = false;
app.tick_count = 1;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert!(app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_readarr_on_tick_should_refresh() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(app.should_refresh);
assert!(!app.data.readarr_data.prompt_confirm);
}
#[tokio::test]
async fn test_readarr_on_tick_should_refresh_does_not_cancel_prompt_requests() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_loading = true;
app.is_routing = true;
app.should_refresh = true;
app.is_first_render = false;
app.tick_count = 1;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
assert!(app.should_refresh);
assert!(!app.data.readarr_data.prompt_confirm);
assert!(!app.cancellation_token.is_cancelled());
}
#[tokio::test]
async fn test_readarr_on_tick_network_tick_frequency() {
let (tx, mut rx) = mpsc::channel::<NetworkEvent>(500);
let mut app = App::test_default();
app.data.readarr_data.prompt_confirm = true;
app.network_tx = Some(tx);
app.is_first_render = false;
app.tick_count = 2;
app.tick_until_poll = 2;
app.readarr_on_tick(ActiveReadarrBlock::Downloads).await;
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetQualityProfiles.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetMetadataProfiles.into()
);
assert_eq!(rx.recv().await.unwrap(), ReadarrEvent::GetTags.into());
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetRootFolders.into()
);
assert_eq!(
rx.recv().await.unwrap(),
ReadarrEvent::GetDownloads(500).into()
);
assert!(app.is_loading);
}
#[tokio::test]
async fn test_extract_add_new_author_search_query() {
let app = App::test_default_fully_populated();
let query = app.extract_add_new_author_search_query().await;
assert_str_eq!(query, "Test Author");
}
#[tokio::test]
#[should_panic(expected = "Add author search is empty")]
async fn test_extract_add_new_author_search_query_panics_when_the_query_is_not_set() {
let app = App::test_default();
app.extract_add_new_author_search_query().await;
}
#[tokio::test]
async fn test_extract_author_id() {
let mut app = App::test_default();
app.data.readarr_data.authors.set_items(vec![Author {
id: 1,
..Author::default()
}]);
assert_eq!(app.extract_author_id().await, 1);
}
#[tokio::test]
async fn test_extract_book_id() {
let mut app = App::test_default();
app.data.readarr_data.books.set_items(vec![Book {
id: 2,
..Book::default()
}]);
assert_eq!(app.extract_book_id().await, 2);
}
#[tokio::test]
async fn test_extract_readarr_indexer_id() {
let mut app = App::test_default();
app.data.readarr_data.indexers.set_items(vec![Indexer {
id: 3,
..Indexer::default()
}]);
assert_eq!(app.extract_readarr_indexer_id().await, 3);
}
}
+15 -21
View File
@@ -11,7 +11,7 @@ pub mod sonarr_context_clues;
#[path = "sonarr_tests.rs"] #[path = "sonarr_tests.rs"]
mod sonarr_tests; mod sonarr_tests;
impl<'a> App<'a> { impl App<'_> {
pub(super) async fn dispatch_by_sonarr_block(&mut self, active_sonarr_block: &ActiveSonarrBlock) { pub(super) async fn dispatch_by_sonarr_block(&mut self, active_sonarr_block: &ActiveSonarrBlock) {
match active_sonarr_block { match active_sonarr_block {
ActiveSonarrBlock::Series => { ActiveSonarrBlock::Series => {
@@ -53,26 +53,24 @@ impl<'a> App<'a> {
) )
.await; .await;
self self
.dispatch_network_event(SonarrEvent::GetDownloads.into()) .dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await; .await;
} }
ActiveSonarrBlock::SeasonHistory => { ActiveSonarrBlock::SeasonHistory => {
if !self.data.sonarr_data.seasons.is_empty() { if !self.data.sonarr_data.seasons.is_empty() {
let (series_id, season_number) = self.extract_series_id_season_number_tuple().await;
self self
.dispatch_network_event( .dispatch_network_event(SonarrEvent::GetSeasonHistory(series_id, season_number).into())
SonarrEvent::GetSeasonHistory(self.extract_series_id_season_number_tuple().await)
.into(),
)
.await; .await;
} }
} }
ActiveSonarrBlock::ManualSeasonSearch => { ActiveSonarrBlock::ManualSeasonSearch => {
match self.data.sonarr_data.season_details_modal.as_ref() { match self.data.sonarr_data.season_details_modal.as_ref() {
Some(season_details_modal) if season_details_modal.season_releases.is_empty() => { Some(season_details_modal) if season_details_modal.season_releases.is_empty() => {
let (series_id, season_number) = self.extract_series_id_season_number_tuple().await;
self self
.dispatch_network_event( .dispatch_network_event(
SonarrEvent::GetSeasonReleases(self.extract_series_id_season_number_tuple().await) SonarrEvent::GetSeasonReleases(series_id, season_number).into(),
.into(),
) )
.await; .await;
} }
@@ -94,9 +92,10 @@ impl<'a> App<'a> {
.await; .await;
} }
ActiveSonarrBlock::ManualEpisodeSearch => { ActiveSonarrBlock::ManualEpisodeSearch => {
if let Some(season_details_modal) = self.data.sonarr_data.season_details_modal.as_ref() { if let Some(season_details_modal) = self.data.sonarr_data.season_details_modal.as_ref()
if let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref() { && let Some(episode_details_modal) = season_details_modal.episode_details_modal.as_ref()
if episode_details_modal.episode_releases.is_empty() { && episode_details_modal.episode_releases.is_empty()
{
self self
.dispatch_network_event( .dispatch_network_event(
SonarrEvent::GetEpisodeReleases(self.extract_episode_id().await).into(), SonarrEvent::GetEpisodeReleases(self.extract_episode_id().await).into(),
@@ -104,11 +103,9 @@ impl<'a> App<'a> {
.await; .await;
} }
} }
}
}
ActiveSonarrBlock::Downloads => { ActiveSonarrBlock::Downloads => {
self self
.dispatch_network_event(SonarrEvent::GetDownloads.into()) .dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await; .await;
} }
ActiveSonarrBlock::Blocklist => { ActiveSonarrBlock::Blocklist => {
@@ -187,12 +184,9 @@ impl<'a> App<'a> {
async fn check_for_sonarr_prompt_action(&mut self) { async fn check_for_sonarr_prompt_action(&mut self) {
if self.data.sonarr_data.prompt_confirm { if self.data.sonarr_data.prompt_confirm {
self.data.sonarr_data.prompt_confirm = false; self.data.sonarr_data.prompt_confirm = false;
if let Some(sonarr_event) = &self.data.sonarr_data.prompt_confirm_action { if let Some(sonarr_event) = self.data.sonarr_data.prompt_confirm_action.take() {
self self.dispatch_network_event(sonarr_event.into()).await;
.dispatch_network_event(sonarr_event.clone().into())
.await;
self.should_refresh = true; self.should_refresh = true;
self.data.sonarr_data.prompt_confirm_action = None;
} }
} }
} }
@@ -218,7 +212,7 @@ impl<'a> App<'a> {
} }
} }
if self.tick_count % self.tick_until_poll == 0 { if self.tick_count.is_multiple_of(self.tick_until_poll) {
self.refresh_sonarr_metadata().await; self.refresh_sonarr_metadata().await;
} }
} }
@@ -237,7 +231,7 @@ impl<'a> App<'a> {
.dispatch_network_event(SonarrEvent::GetRootFolders.into()) .dispatch_network_event(SonarrEvent::GetRootFolders.into())
.await; .await;
self self
.dispatch_network_event(SonarrEvent::GetDownloads.into()) .dispatch_network_event(SonarrEvent::GetDownloads(500).into())
.await; .await;
self self
.dispatch_network_event(SonarrEvent::GetDiskSpace.into()) .dispatch_network_event(SonarrEvent::GetDiskSpace.into())
+109 -23
View File
@@ -1,4 +1,14 @@
use crate::app::{context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS}; use crate::app::context_clues::{
BARE_POPUP_CONTEXT_CLUES, CONFIRMATION_PROMPT_CONTEXT_CLUES, ContextClueProvider,
SYSTEM_TASKS_CONTEXT_CLUES,
};
use crate::app::{App, context_clues::ContextClue, key_binding::DEFAULT_KEYBINDINGS};
use crate::models::Route;
use crate::models::servarr_data::sonarr::sonarr_data::{
ADD_SERIES_BLOCKS, ActiveSonarrBlock, DELETE_SERIES_BLOCKS, EDIT_INDEXER_BLOCKS,
EDIT_SERIES_BLOCKS, EPISODE_DETAILS_BLOCKS, INDEXER_SETTINGS_BLOCKS, SEASON_DETAILS_BLOCKS,
SERIES_DETAILS_BLOCKS, SERIES_OVERVIEW_BLOCKS,
};
#[cfg(test)] #[cfg(test)]
#[path = "sonarr_context_clues_tests.rs"] #[path = "sonarr_context_clues_tests.rs"]
@@ -9,9 +19,13 @@ pub static ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.esc, "edit search"), (DEFAULT_KEYBINDINGS.esc, "edit search"),
]; ];
pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [ pub static SERIES_CONTEXT_CLUES: [ContextClue; 11] = [
(DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc), (DEFAULT_KEYBINDINGS.add, DEFAULT_KEYBINDINGS.add.desc),
(DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc), (DEFAULT_KEYBINDINGS.edit, DEFAULT_KEYBINDINGS.edit.desc),
(
DEFAULT_KEYBINDINGS.toggle_monitoring,
DEFAULT_KEYBINDINGS.toggle_monitoring.desc,
),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc), (DEFAULT_KEYBINDINGS.delete, DEFAULT_KEYBINDINGS.delete.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc), (DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
@@ -25,7 +39,7 @@ pub static SERIES_CONTEXT_CLUES: [ContextClue; 10] = [
(DEFAULT_KEYBINDINGS.esc, "cancel filter"), (DEFAULT_KEYBINDINGS.esc, "cancel filter"),
]; ];
pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 9] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -42,19 +56,16 @@ pub static SERIES_DETAILS_CONTEXT_CLUES: [ContextClue; 8] = [
DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.view, "view overview"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ pub static SERIES_OVERVIEW_CONTEXT_CLUES: [ContextClue; 2] = [
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.search, DEFAULT_KEYBINDINGS.search.desc),
(DEFAULT_KEYBINDINGS.filter, DEFAULT_KEYBINDINGS.filter.desc),
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
), ),
(DEFAULT_KEYBINDINGS.esc, "cancel filter"), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [ pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
@@ -75,12 +86,7 @@ pub static SERIES_HISTORY_CONTEXT_CLUES: [ContextClue; 9] = [
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
]; ];
pub static SEASON_DETAILS_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 2] = [ pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 7] = [
(DEFAULT_KEYBINDINGS.submit, "episode details"),
(DEFAULT_KEYBINDINGS.delete, "delete episode"),
];
pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -95,9 +101,11 @@ pub static SEASON_DETAILS_CONTEXT_CLUES: [ContextClue; 5] = [
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
(DEFAULT_KEYBINDINGS.submit, "episode details"),
(DEFAULT_KEYBINDINGS.delete, "delete episode"),
]; ];
pub static SEASON_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [ pub static SEASON_HISTORY_CONTEXT_CLUES: [ContextClue; 7] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -109,10 +117,11 @@ pub static SEASON_HISTORY_CONTEXT_CLUES: [ContextClue; 6] = [
DEFAULT_KEYBINDINGS.auto_search, DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, "cancel filter/close"), (DEFAULT_KEYBINDINGS.esc, "cancel filter/close"),
]; ];
pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [ pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -122,10 +131,11 @@ pub static MANUAL_SEASON_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [ pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 5] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc, DEFAULT_KEYBINDINGS.refresh.desc,
@@ -135,12 +145,10 @@ pub static MANUAL_EPISODE_SEARCH_CONTEXT_CLUES: [ContextClue; 4] = [
DEFAULT_KEYBINDINGS.auto_search.desc, DEFAULT_KEYBINDINGS.auto_search.desc,
), ),
(DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc), (DEFAULT_KEYBINDINGS.sort, DEFAULT_KEYBINDINGS.sort.desc),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static DETAILS_CONTEXTUAL_CONTEXT_CLUES: [ContextClue; 1] =
[(DEFAULT_KEYBINDINGS.submit, "details")];
pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [
( (
DEFAULT_KEYBINDINGS.refresh, DEFAULT_KEYBINDINGS.refresh,
@@ -153,7 +161,85 @@ pub static EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 3] = [
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub static SYSTEM_TASKS_CONTEXT_CLUES: [ContextClue; 2] = [ pub static SELECTABLE_EPISODE_DETAILS_CONTEXT_CLUES: [ContextClue; 4] = [
(DEFAULT_KEYBINDINGS.submit, "start task"), (
DEFAULT_KEYBINDINGS.refresh,
DEFAULT_KEYBINDINGS.refresh.desc,
),
(
DEFAULT_KEYBINDINGS.auto_search,
DEFAULT_KEYBINDINGS.auto_search.desc,
),
(DEFAULT_KEYBINDINGS.submit, "details"),
(DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc), (DEFAULT_KEYBINDINGS.esc, DEFAULT_KEYBINDINGS.esc.desc),
]; ];
pub(in crate::app) struct SonarrContextClueProvider;
impl ContextClueProvider for SonarrContextClueProvider {
fn get_context_clues(app: &mut App<'_>) -> Option<&'static [ContextClue]> {
let Route::Sonarr(active_sonarr_block, _) = app.get_current_route() else {
panic!("SonarrContextClueProvider::get_context_clues called with non-Sonarr route");
};
match active_sonarr_block {
_ if SERIES_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.series_info_tabs
.get_active_route_contextual_help(),
_ if SERIES_OVERVIEW_BLOCKS.contains(&active_sonarr_block) => {
Some(&SERIES_OVERVIEW_CONTEXT_CLUES)
}
_ if SEASON_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.season_details_modal
.as_ref()
.unwrap()
.season_details_tabs
.get_active_route_contextual_help(),
_ if EPISODE_DETAILS_BLOCKS.contains(&active_sonarr_block) => app
.data
.sonarr_data
.season_details_modal
.as_ref()
.unwrap()
.episode_details_modal
.as_ref()
.unwrap()
.episode_details_tabs
.get_active_route_contextual_help(),
ActiveSonarrBlock::TestAllIndexers
| ActiveSonarrBlock::AddSeriesSearchInput
| ActiveSonarrBlock::AddSeriesEmptySearchResults
| ActiveSonarrBlock::SystemLogs
| ActiveSonarrBlock::SystemUpdates => Some(&BARE_POPUP_CONTEXT_CLUES),
_ if EDIT_INDEXER_BLOCKS.contains(&active_sonarr_block)
|| INDEXER_SETTINGS_BLOCKS.contains(&active_sonarr_block)
|| EDIT_SERIES_BLOCKS.contains(&active_sonarr_block) =>
{
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
_ if DELETE_SERIES_BLOCKS.contains(&active_sonarr_block) => {
Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES)
}
ActiveSonarrBlock::AddSeriesPrompt
| ActiveSonarrBlock::AddSeriesSelectMonitor
| ActiveSonarrBlock::AddSeriesSelectSeriesType
| ActiveSonarrBlock::AddSeriesSelectQualityProfile
| ActiveSonarrBlock::AddSeriesSelectLanguageProfile
| ActiveSonarrBlock::AddSeriesSelectRootFolder
| ActiveSonarrBlock::AddSeriesTagsInput
| ActiveSonarrBlock::SystemTaskStartConfirmPrompt => Some(&CONFIRMATION_PROMPT_CONTEXT_CLUES),
_ if ADD_SERIES_BLOCKS.contains(&active_sonarr_block) => {
Some(&ADD_SERIES_SEARCH_RESULTS_CONTEXT_CLUES)
}
ActiveSonarrBlock::SystemTasks => Some(&SYSTEM_TASKS_CONTEXT_CLUES),
_ => app
.data
.sonarr_data
.main_tabs
.get_active_route_contextual_help(),
}
}
}
File diff suppressed because it is too large. Load diff
+26 -26
View File
@@ -16,7 +16,7 @@ mod tests {
}, },
sonarr_models::{Season, Series, SonarrRelease}, sonarr_models::{Season, Series, SonarrRelease},
}, },
network::{sonarr_network::SonarrEvent, NetworkEvent}, network::{NetworkEvent, sonarr_network::SonarrEvent},
}; };
#[tokio::test] #[tokio::test]
@@ -107,7 +107,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -132,7 +132,7 @@ mod tests {
assert!(app.is_loading); assert!(app.is_loading);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonHistory((1, 1)).into() SonarrEvent::GetSeasonHistory(1, 1).into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -175,7 +175,7 @@ mod tests {
assert!(app.is_loading); assert!(app.is_loading);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetSeasonReleases((1, 1)).into() SonarrEvent::GetSeasonReleases(1, 1).into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -185,7 +185,7 @@ mod tests {
async fn test_dispatch_by_manual_season_search_block_is_loading() { async fn test_dispatch_by_manual_season_search_block_is_loading() {
let mut app = App { let mut app = App {
is_loading: true, is_loading: true,
..App::default() ..App::test_default()
}; };
app app
@@ -199,7 +199,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_manual_season_search_block_season_releases_non_empty() { async fn test_dispatch_by_manual_season_search_block_season_releases_non_empty() {
let mut app = App::default(); let mut app = App::test_default();
let mut season_details_modal = SeasonDetailsModal::default(); let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal season_details_modal
.season_releases .season_releases
@@ -304,7 +304,7 @@ mod tests {
async fn test_dispatch_by_manual_episode_search_block_is_loading() { async fn test_dispatch_by_manual_episode_search_block_is_loading() {
let mut app = App { let mut app = App {
is_loading: true, is_loading: true,
..App::default() ..App::test_default()
}; };
app app
@@ -318,7 +318,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_dispatch_by_manual_episode_search_block_episode_releases_non_empty() { async fn test_dispatch_by_manual_episode_search_block_episode_releases_non_empty() {
let mut app = App::default(); let mut app = App::test_default();
let mut episode_details_modal = EpisodeDetailsModal::default(); let mut episode_details_modal = EpisodeDetailsModal::default();
episode_details_modal episode_details_modal
.episode_releases .episode_releases
@@ -366,7 +366,7 @@ mod tests {
assert!(app.is_loading); assert!(app.is_loading);
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0); assert_eq!(app.tick_count, 0);
@@ -554,7 +554,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() { async fn test_check_for_sonarr_prompt_action_no_prompt_confirm() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.prompt_confirm = false; app.data.sonarr_data.prompt_confirm = false;
app.check_for_sonarr_prompt_action().await; app.check_for_sonarr_prompt_action().await;
@@ -604,7 +604,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
@@ -642,7 +642,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
@@ -667,7 +667,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
} }
@@ -692,7 +692,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(app.should_refresh); assert!(app.should_refresh);
assert!(!app.data.sonarr_data.prompt_confirm); assert!(!app.data.sonarr_data.prompt_confirm);
@@ -709,7 +709,7 @@ mod tests {
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(app.is_loading); assert!(app.is_loading);
assert!(app.should_refresh); assert!(app.should_refresh);
@@ -743,14 +743,14 @@ mod tests {
); );
assert_eq!( assert_eq!(
sync_network_rx.recv().await.unwrap(), sync_network_rx.recv().await.unwrap(),
SonarrEvent::GetDownloads.into() SonarrEvent::GetDownloads(500).into()
); );
assert!(app.is_loading); assert!(app.is_loading);
} }
#[tokio::test] #[tokio::test]
async fn test_populate_seasons_table_unfiltered() { async fn test_populate_seasons_table_unfiltered() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series { app.data.sonarr_data.series.set_items(vec![Series {
seasons: Some(vec![Season::default()]), seasons: Some(vec![Season::default()]),
..Series::default() ..Series::default()
@@ -770,7 +770,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_populate_seasons_table_filtered() { async fn test_populate_seasons_table_filtered() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.series.set_filtered_items(vec![Series { app.data.sonarr_data.series.set_filtered_items(vec![Series {
seasons: Some(vec![Season::default()]), seasons: Some(vec![Season::default()]),
..Series::default() ..Series::default()
@@ -790,7 +790,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_extract_episode_id() { async fn test_extract_episode_id() {
let mut app = App::default(); let mut app = App::test_default();
let mut season_details_modal = SeasonDetailsModal::default(); let mut season_details_modal = SeasonDetailsModal::default();
season_details_modal.episodes.set_items(vec![Episode { season_details_modal.episodes.set_items(vec![Episode {
id: 1, id: 1,
@@ -804,14 +804,14 @@ mod tests {
#[tokio::test] #[tokio::test]
#[should_panic(expected = "Season details have not been loaded")] #[should_panic(expected = "Season details have not been loaded")]
async fn test_extract_episode_id_requires_season_details_modal_to_be_some() { async fn test_extract_episode_id_requires_season_details_modal_to_be_some() {
let app = App::default(); let app = App::test_default();
assert_eq!(app.extract_episode_id().await, 0); assert_eq!(app.extract_episode_id().await, 0);
} }
#[tokio::test] #[tokio::test]
async fn test_extract_series_id() { async fn test_extract_series_id() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series { app.data.sonarr_data.series.set_items(vec![Series {
id: 1, id: 1,
..Series::default() ..Series::default()
@@ -822,7 +822,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_extract_series_id_season_number_tuple() { async fn test_extract_series_id_season_number_tuple() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.series.set_items(vec![Series { app.data.sonarr_data.series.set_items(vec![Series {
id: 1, id: 1,
..Series::default() ..Series::default()
@@ -837,7 +837,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn test_extract_add_new_series_search_query() { async fn test_extract_add_new_series_search_query() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.add_series_search = Some("test search".into()); app.data.sonarr_data.add_series_search = Some("test search".into());
assert_str_eq!( assert_str_eq!(
@@ -849,14 +849,14 @@ mod tests {
#[tokio::test] #[tokio::test]
#[should_panic(expected = "Add series search is empty")] #[should_panic(expected = "Add series search is empty")]
async fn test_extract_add_new_series_search_query_panics_when_the_query_is_not_set() { async fn test_extract_add_new_series_search_query_panics_when_the_query_is_not_set() {
let app = App::default(); let app = App::test_default();
app.extract_add_new_series_search_query().await; app.extract_add_new_series_search_query().await;
} }
#[tokio::test] #[tokio::test]
async fn test_extract_sonarr_indexer_id() { async fn test_extract_sonarr_indexer_id() {
let mut app = App::default(); let mut app = App::test_default();
app.data.sonarr_data.indexers.set_items(vec![Indexer { app.data.sonarr_data.indexers.set_items(vec![Indexer {
id: 1, id: 1,
..Indexer::default() ..Indexer::default()
@@ -871,7 +871,7 @@ mod tests {
network_tx: Some(sync_network_tx), network_tx: Some(sync_network_tx),
tick_count: 1, tick_count: 1,
is_first_render: false, is_first_render: false,
..App::default() ..App::test_default()
}; };
app.data.sonarr_data.prompt_confirm = true; app.data.sonarr_data.prompt_confirm = true;
+102 -14
View File
@@ -2,30 +2,42 @@
mod tests { mod tests {
use std::sync::Arc; use std::sync::Arc;
use clap::{error::ErrorKind, CommandFactory}; use clap::{CommandFactory, error::ErrorKind};
use mockall::predicate::eq; use mockall::predicate::eq;
use rstest::rstest; use rstest::rstest;
use serde_json::json; use serde_json::json;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::readarr::ReadarrCommand;
use crate::network::lidarr_network::LidarrEvent;
use crate::network::readarr_network::ReadarrEvent;
use crate::{ use crate::{
Cli,
app::App, app::App,
cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand}, cli::{handle_command, mutex_flags_or_option, radarr::RadarrCommand, sonarr::SonarrCommand},
models::{ models::{
Serdeable,
lidarr_models::{
BlocklistItem as LidarrBlocklistItem, BlocklistResponse as LidarrBlocklistResponse,
LidarrSerdeable,
},
radarr_models::{ radarr_models::{
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse, BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable, RadarrSerdeable,
}, },
readarr_models::{
BlocklistItem as ReadarrBlocklistItem, BlocklistResponse as ReadarrBlocklistResponse,
ReadarrSerdeable,
},
sonarr_models::{ sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse, BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable, SonarrSerdeable,
}, },
Serdeable,
}, },
network::{ network::{
radarr_network::RadarrEvent, sonarr_network::SonarrEvent, MockNetworkTrait, NetworkEvent, MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent, sonarr_network::SonarrEvent,
}, },
Cli,
}; };
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -33,7 +45,7 @@ mod tests {
fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) { fn test_servarr_subcommand_requires_subcommand(#[values("radarr", "sonarr")] subcommand: &str) {
let result = Cli::command().try_get_matches_from(["managarr", subcommand]); let result = Cli::command().try_get_matches_from(["managarr", subcommand]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
@@ -45,21 +57,35 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_sonarr_subcommand_delegates_to_sonarr() { fn test_sonarr_subcommand_delegates_to_sonarr() {
let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]); let result = Cli::command().try_get_matches_from(["managarr", "sonarr", "list", "series"]);
assert!(result.is_ok()); assert_ok!(&result);
}
#[test]
fn test_lidarr_subcommand_delegates_to_lidarr() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_readarr_subcommand_delegates_to_readarr() {
let result = Cli::command().try_get_matches_from(["managarr", "readarr", "list", "authors"]);
assert_ok!(&result);
} }
#[test] #[test]
fn test_completions_requires_argument() { fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]); let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
@@ -70,7 +96,7 @@ mod tests {
fn test_completions_invalid_argument() { fn test_completions_invalid_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions", "test"]); let result = Cli::command().try_get_matches_from(["managarr", "completions", "test"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -78,7 +104,7 @@ mod tests {
fn test_completions_satisfied_with_argument() { fn test_completions_satisfied_with_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions", "bash"]); let result = Cli::command().try_get_matches_from(["managarr", "completions", "bash"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[rstest] #[rstest]
@@ -136,12 +162,12 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = RadarrCommand::ClearBlocklist.into(); let clear_blocklist_command = RadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -167,11 +193,73 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = SonarrCommand::ClearBlocklist.into(); let clear_blocklist_command = SonarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await; let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert!(result.is_ok()); assert_ok!(&result);
}
#[tokio::test]
async fn test_cli_handler_delegates_lidarr_commands_to_the_lidarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
LidarrBlocklistResponse {
records: vec![LidarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = LidarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_cli_handler_delegates_readarr_commands_to_the_readarr_cli_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::BlocklistResponse(
ReadarrBlocklistResponse {
records: vec![ReadarrBlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(ReadarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = ReadarrCommand::ClearBlocklist.into();
let result = handle_command(&app_arc, clear_blocklist_command, &mut mock_network).await;
assert_ok!(&result);
} }
} }
+223
View File
@@ -0,0 +1,223 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::lidarr_models::{
AddArtistBody, AddArtistOptions, AddLidarrRootFolderBody, MonitorType, NewItemMonitorType,
},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrAddCommand {
#[command(about = "Add a new artist to your Lidarr library")]
Artist {
#[arg(
long,
help = "The MusicBrainz foreign artist ID of the artist you wish to add to your library",
required = true
)]
foreign_artist_id: String,
#[arg(long, help = "The name of the artist", required = true)]
artist_name: String,
#[arg(
long,
help = "The root folder path where all artist data and metadata should live",
required = true
)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the quality profile to use for this artist",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the metadata profile to use for this artist",
required = true
)]
metadata_profile_id: i64,
#[arg(long, help = "Disable monitoring for this artist")]
disable_monitoring: bool,
#[arg(
long,
help = "Tag IDs to tag the artist with",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
#[arg(
long,
help = "What Lidarr should monitor for this artist",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "How Lidarr should monitor new items for this artist",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Tell Lidarr to not start a search for missing albums once the artist is added to your library"
)]
no_search_for_missing_albums: bool,
},
#[command(about = "Add a new root folder")]
RootFolder {
#[arg(long, help = "The name of the root folder", required = true)]
name: String,
#[arg(long, help = "The path of the new root folder", required = true)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the default quality profile for artists in this root folder",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the default metadata profile for artists in this root folder",
required = true
)]
metadata_profile_id: i64,
#[arg(
long,
help = "The default monitor option for artists in this root folder",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "The default monitor new items option for artists in this root folder",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Default tag IDs for artists in this root folder",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
},
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<LidarrAddCommand> for Command {
fn from(value: LidarrAddCommand) -> Self {
Command::Lidarr(LidarrCommand::Add(value))
}
}
pub(super) struct LidarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrAddCommand> for LidarrAddCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrAddCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrAddCommand::Artist {
foreign_artist_id,
artist_name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
disable_monitoring,
tag: tags,
monitor,
monitor_new_items,
no_search_for_missing_albums,
} => {
let body = AddArtistBody {
foreign_artist_id,
artist_name,
monitored: !disable_monitoring,
root_folder_path,
quality_profile_id,
metadata_profile_id,
tags,
tag_input_string: None,
add_options: AddArtistOptions {
monitor,
monitor_new_items,
search_for_missing_albums: !no_search_for_missing_albums,
},
};
let resp = self
.network
.handle_network_event(LidarrEvent::AddArtist(body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrAddCommand::RootFolder {
name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
monitor,
monitor_new_items,
tag: tags,
} => {
let add_root_folder_body = AddLidarrRootFolderBody {
name,
path: root_folder_path,
default_quality_profile_id: quality_profile_id,
default_metadata_profile_id: metadata_profile_id,
default_monitor_option: monitor,
default_new_item_monitor_option: monitor_new_items,
default_tags: tags,
tag_input_string: None,
};
let resp = self
.network
.handle_network_event(LidarrEvent::AddRootFolder(add_root_folder_body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrAddCommand::Tag { name } => {
let resp = self
.network
.handle_network_event(LidarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+561
View File
@@ -0,0 +1,561 @@
#[cfg(test)]
mod tests {
use clap::{CommandFactory, Parser, error::ErrorKind};
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, add_command_handler::LidarrAddCommand},
},
models::lidarr_models::{MonitorType, NewItemMonitorType},
};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_add_command_from() {
let command = LidarrAddCommand::Tag {
name: String::new(),
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Add(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_add_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "root-folder"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_root_folder_success() {
let expected_args = LidarrAddCommand::RootFolder {
name: "Music".to_owned(),
root_folder_path: "/nfs/test".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
tag: vec![],
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"root-folder",
"--name",
"Music",
"--root-folder-path",
"/nfs/test",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_tag_success() {
let expected_args = LidarrAddCommand::Tag {
name: "test".to_owned(),
};
let result = Cli::try_parse_from(["managarr", "lidarr", "add", "tag", "--name", "test"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_foreign_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_artist_name() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_root_folder_path() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_quality_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--metadata-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_requires_metadata_profile_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_add_artist_success_with_required_args_only() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
disable_monitoring: false,
tag: vec![],
monitor: MonitorType::default(),
monitor_new_items: NewItemMonitorType::default(),
no_search_for_missing_albums: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_success_with_all_args() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 2,
disable_monitoring: true,
tag: vec![1, 2],
monitor: MonitorType::Future,
monitor_new_items: NewItemMonitorType::New,
no_search_for_missing_albums: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--disable-monitoring",
"--tag",
"1",
"--tag",
"2",
"--monitor",
"future",
"--monitor-new-items",
"new",
"--no-search-for-missing-albums",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
#[test]
fn test_add_artist_monitor_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--monitor",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_artist_new_item_monitor_type_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--monitor-new-items",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_add_artist_tags_is_repeatable() {
let expected_args = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 2,
disable_monitoring: false,
tag: vec![1, 2],
monitor: MonitorType::default(),
monitor_new_items: NewItemMonitorType::default(),
no_search_for_missing_albums: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"add",
"artist",
"--foreign-artist-id",
"test-id",
"--artist-name",
"Test Artist",
"--root-folder-path",
"/music",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"2",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::{
AddArtistBody, AddArtistOptions, AddLidarrRootFolderBody, LidarrSerdeable, MonitorType,
NewItemMonitorType,
};
use crate::network::lidarr_network::LidarrEvent;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[tokio::test]
async fn test_handle_add_root_folder_command() {
let expected_root_folder_path = "/nfs/test".to_owned();
let expected_add_root_folder_body = AddLidarrRootFolderBody {
name: "Music".to_owned(),
path: expected_root_folder_path.clone(),
default_quality_profile_id: 1,
default_metadata_profile_id: 1,
default_monitor_option: MonitorType::All,
default_new_item_monitor_option: NewItemMonitorType::All,
default_tags: vec![1, 2],
tag_input_string: None,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddRootFolder(expected_add_root_folder_body.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_root_folder_command = LidarrAddCommand::RootFolder {
name: "Music".to_owned(),
root_folder_path: expected_root_folder_path,
quality_profile_id: 1,
metadata_profile_id: 1,
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
tag: vec![1, 2],
};
let result =
LidarrAddCommandHandler::with(&app_arc, add_root_folder_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrAddCommand::Tag {
name: expected_tag_name,
};
let result = LidarrAddCommandHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_add_artist_command() {
let expected_body = AddArtistBody {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
monitored: false,
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
tags: vec![1, 2],
tag_input_string: None,
add_options: AddArtistOptions {
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
search_for_missing_albums: false,
},
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::AddArtist(expected_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_artist_command = LidarrAddCommand::Artist {
foreign_artist_id: "test-id".to_owned(),
artist_name: "Test Artist".to_owned(),
root_folder_path: "/music".to_owned(),
quality_profile_id: 1,
metadata_profile_id: 1,
disable_monitoring: true,
tag: vec![1, 2],
monitor: MonitorType::All,
monitor_new_items: NewItemMonitorType::All,
no_search_for_missing_albums: true,
};
let result = LidarrAddCommandHandler::with(&app_arc, add_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+181
View File
@@ -0,0 +1,181 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::lidarr_models::DeleteParams,
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "delete_command_handler_tests.rs"]
mod delete_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrDeleteCommand {
#[command(about = "Delete an album from your Lidarr library")]
Album {
#[arg(long, help = "The ID of the album to delete", required = true)]
album_id: i64,
#[arg(long, help = "Delete the album files from disk as well")]
delete_files_from_disk: bool,
#[arg(long, help = "Add a list exclusion for this album")]
add_list_exclusion: bool,
},
#[command(about = "Delete the specified item from the Lidarr 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 track file from disk")]
TrackFile {
#[arg(long, help = "The ID of the track file to delete", required = true)]
track_file_id: i64,
},
#[command(about = "Delete an artist from your Lidarr library")]
Artist {
#[arg(long, help = "The ID of the artist to delete", required = true)]
artist_id: i64,
#[arg(long, help = "Delete the artist files from disk as well")]
delete_files_from_disk: bool,
#[arg(long, help = "Add a list exclusion for this artist")]
add_list_exclusion: bool,
},
#[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 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 the tag with the specified ID")]
Tag {
#[arg(long, help = "The ID of the tag to delete", required = true)]
tag_id: i64,
},
}
impl From<LidarrDeleteCommand> for Command {
fn from(value: LidarrDeleteCommand) -> Self {
Command::Lidarr(LidarrCommand::Delete(value))
}
}
pub(super) struct LidarrDeleteCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrDeleteCommand> for LidarrDeleteCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrDeleteCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrDeleteCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrDeleteCommand::Album {
album_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_album_params = DeleteParams {
id: album_id,
delete_files: delete_files_from_disk,
add_import_list_exclusion: add_list_exclusion,
};
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteAlbum(delete_album_params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::BlocklistItem { blocklist_item_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteBlocklistItem(blocklist_item_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::TrackFile { track_file_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteTrackFile(track_file_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Artist {
artist_id,
delete_files_from_disk,
add_list_exclusion,
} => {
let delete_artist_params = DeleteParams {
id: artist_id,
delete_files: delete_files_from_disk,
add_import_list_exclusion: add_list_exclusion,
};
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteArtist(delete_artist_params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Download { download_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteDownload(download_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Indexer { indexer_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteIndexer(indexer_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::RootFolder { root_folder_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteRootFolder(root_folder_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrDeleteCommand::Tag { tag_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::DeleteTag(tag_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,595 @@
#[cfg(test)]
mod tests {
use crate::{
Cli,
cli::{
Command,
lidarr::{LidarrCommand, delete_command_handler::LidarrDeleteCommand},
},
};
use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_delete_command_from() {
let command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Delete(command)));
}
mod cli {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_delete_album_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "album"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_album_defaults() {
let expected_args = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "lidarr", "delete", "album", "--album-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_album_all_args_defined() {
let expected_args = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"album",
"--album-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_blocklist_item_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "blocklist-item"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_blocklist_item_success() {
let expected_args = LidarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"blocklist-item",
"--blocklist-item-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_track_file_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "track-file"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_track_file_success() {
let expected_args = LidarrDeleteCommand::TrackFile { track_file_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"track-file",
"--track-file-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_artist_defaults() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: false,
add_list_exclusion: false,
};
let result =
Cli::try_parse_from(["managarr", "lidarr", "delete", "artist", "--artist-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_artist_all_args_defined() {
let expected_args = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"artist",
"--artist-id",
"1",
"--delete-files-from-disk",
"--add-list-exclusion",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_download_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "download"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_download_success() {
let expected_args = LidarrDeleteCommand::Download { download_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"download",
"--download-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "indexer"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_indexer_success() {
let expected_args = LidarrDeleteCommand::Indexer { indexer_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"indexer",
"--indexer-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_root_folder_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "root-folder"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_root_folder_success() {
let expected_args = LidarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"delete",
"root-folder",
"--root-folder-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args);
}
#[test]
fn test_delete_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete", "tag"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_delete_tag_success() {
let expected_args = LidarrDeleteCommand::Tag { tag_id: 1 };
let result = Cli::try_parse_from(["managarr", "lidarr", "delete", "tag", "--tag-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Delete(delete_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
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::{
CliCommandHandler,
lidarr::delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler},
},
models::{
Serdeable,
lidarr_models::{DeleteParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_delete_album_command() {
let expected_delete_album_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteAlbum(expected_delete_album_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_album_command = LidarrDeleteCommand::Album {
album_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_album_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::DeleteBlocklistItem(expected_blocklist_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_blocklist_item_command = LidarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1,
};
let result = LidarrDeleteCommandHandler::with(
&app_arc,
delete_blocklist_item_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_track_file_command() {
let expected_track_file_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteTrackFile(expected_track_file_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_track_file_command = LidarrDeleteCommand::TrackFile { track_file_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_track_file_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_delete_artist_command() {
let expected_delete_artist_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
};
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::DeleteDownload(expected_download_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_download_command = LidarrDeleteCommand::Download { download_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_download_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::DeleteIndexer(expected_indexer_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_indexer_command = LidarrDeleteCommand::Indexer { indexer_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::DeleteRootFolder(expected_root_folder_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_root_folder_command = LidarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_root_folder_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::DeleteTag(expected_tag_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_tag_command = LidarrDeleteCommand::Tag { tag_id: 1 };
let result =
LidarrDeleteCommandHandler::with(&app_arc, delete_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+343
View File
@@ -0,0 +1,343 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, ArgGroup, Subcommand};
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::models::Serdeable;
use crate::models::lidarr_models::LidarrSerdeable;
use crate::models::servarr_models::{EditIndexerParams, IndexerSettings};
use crate::{
app::App,
cli::{CliCommandHandler, Command, mutex_flags_or_option},
models::lidarr_models::{EditArtistParams, NewItemMonitorType},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "edit_command_handler_tests.rs"]
mod edit_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrEditCommand {
#[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<i64>,
#[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<i64>,
#[arg(
long,
help = "Usenet only: The retention time in days to retain releases. Set to zero to set for unlimited retention"
)]
retention: Option<i64>,
#[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<i64>,
},
#[command(
about = "Edit preferences for the specified artist",
group(
ArgGroup::new("edit_artist")
.args([
"enable_monitoring",
"disable_monitoring",
"monitor_new_items",
"quality_profile_id",
"metadata_profile_id",
"root_folder_path",
"tag",
"clear_tags"
]).required(true)
.multiple(true))
)]
Artist {
#[arg(
long,
help = "The ID of the artist whose settings you want to edit",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "Enable monitoring of this artist in Lidarr so Lidarr will automatically download releases from this artist if they are available",
conflicts_with = "disable_monitoring"
)]
enable_monitoring: bool,
#[arg(
long,
help = "Disable monitoring of this artist so Lidarr does not automatically download releases from this artist if they are available",
conflicts_with = "enable_monitoring"
)]
disable_monitoring: bool,
#[arg(
long,
help = "How Lidarr should monitor new albums from this artist",
value_enum
)]
monitor_new_items: Option<NewItemMonitorType>,
#[arg(long, help = "The ID of the quality profile to use for this artist")]
quality_profile_id: Option<i64>,
#[arg(long, help = "The ID of the metadata profile to use for this artist")]
metadata_profile_id: Option<i64>,
#[arg(
long,
help = "The root folder path where all artist data and metadata should live"
)]
root_folder_path: Option<String>,
#[arg(
long,
help = "Tag IDs to tag this artist with",
value_parser,
action = ArgAction::Append,
conflicts_with = "clear_tags"
)]
tag: Option<Vec<i64>>,
#[arg(long, help = "Clear all tags on this artist", conflicts_with = "tag")]
clear_tags: bool,
},
#[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<String>,
#[arg(
long,
help = "Indicate to Lidarr that this indexer should be used when Lidarr periodically looks for releases via RSS Sync",
conflicts_with = "disable_rss"
)]
enable_rss: bool,
#[arg(
long,
help = "Disable using this indexer when Lidarr periodically looks for releases via RSS Sync",
conflicts_with = "enable_rss"
)]
disable_rss: bool,
#[arg(
long,
help = "Indicate to Lidarr that this indexer should be used when automatic searches are performed via the UI or by Lidarr",
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 Lidarr",
conflicts_with = "enable_automatic_search"
)]
disable_automatic_search: bool,
#[arg(
long,
help = "Indicate to Lidarr 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<String>,
#[arg(long, help = "The API key used to access the indexer's API")]
api_key: Option<String>,
#[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<String>,
#[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<Vec<i64>>,
#[arg(
long,
help = "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Lidarr will still use all enabled indexers for RSS Sync and Searching"
)]
priority: Option<i64>,
#[arg(long, help = "Clear all tags on this indexer", conflicts_with = "tag")]
clear_tags: bool,
},
}
impl From<LidarrEditCommand> for Command {
fn from(value: LidarrEditCommand) -> Self {
Command::Lidarr(LidarrCommand::Edit(value))
}
}
pub(super) struct LidarrEditCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrEditCommand> for LidarrEditCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrEditCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrEditCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrEditCommand::AllIndexerSettings {
maximum_size,
minimum_age,
retention,
rss_sync_interval,
} => {
if let Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(previous_indexer_settings)) = self
.network
.handle_network_event(LidarrEvent::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(LidarrEvent::EditAllIndexerSettings(params).into())
.await?;
"All indexer settings updated".to_owned()
} else {
String::new()
}
}
LidarrEditCommand::Artist {
artist_id,
enable_monitoring,
disable_monitoring,
monitor_new_items,
quality_profile_id,
metadata_profile_id,
root_folder_path,
tag,
clear_tags,
} => {
let monitored_value = mutex_flags_or_option(enable_monitoring, disable_monitoring);
let edit_artist_params = EditArtistParams {
artist_id,
monitored: monitored_value,
monitor_new_items,
quality_profile_id,
metadata_profile_id,
root_folder_path,
tags: tag,
tag_input_string: None,
clear_tags,
};
self
.network
.handle_network_event(LidarrEvent::EditArtist(edit_artist_params).into())
.await?;
"Artist Updated".to_owned()
}
LidarrEditCommand::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,
tag_input_string: None,
priority,
clear_tags,
};
self
.network
.handle_network_event(LidarrEvent::EditIndexer(edit_indexer_params).into())
.await?;
"Indexer updated".to_owned()
}
};
Ok(result)
}
}
@@ -0,0 +1,858 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
lidarr::{LidarrCommand, edit_command_handler::LidarrEditCommand},
};
#[test]
fn test_lidarr_edit_command_from() {
let command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: None,
tag: None,
clear_tags: false,
};
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Edit(command)));
}
mod cli {
use crate::{Cli, models::lidarr_models::NewItemMonitorType};
use super::*;
use clap::{CommandFactory, Parser, error::ErrorKind};
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", "lidarr", "edit", "all-indexer-settings"]);
assert_err!(&result);
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",
"lidarr",
"edit",
"all-indexer-settings",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_all_indexer_settings_only_requires_at_least_one_argument() {
let expected_args = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: None,
retention: None,
rss_sync_interval: None,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_all_indexer_settings_all_arguments_defined() {
let expected_args = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"all-indexer-settings",
"--maximum-size",
"1",
"--minimum-age",
"1",
"--retention",
"1",
"--rss-sync-interval",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_with_artist_id_still_requires_arguments() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_edit_artist_monitoring_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--disable-monitoring",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[test]
fn test_edit_artist_tag_flags_conflict() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
}
#[rstest]
fn test_edit_artist_assert_argument_flags_require_args(
#[values(
"--monitor-new-items",
"--quality-profile-id",
"--metadata-profile-id",
"--root-folder-path",
"--tag"
)]
flag: &str,
) {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
flag,
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_monitor_new_items_validation() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--monitor-new-items",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_edit_artist_only_requires_at_least_one_argument_plus_artist_id() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: Some("/nfs/test".to_owned()),
tag: None,
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--root-folder-path",
"/nfs/test",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_tag_argument_is_repeatable() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: None,
quality_profile_id: None,
metadata_profile_id: None,
root_folder_path: None,
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_artist_all_arguments_defined() {
let expected_args = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: true,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_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",
"lidarr",
"edit",
"artist",
"--artist-id",
"1",
"--enable-monitoring",
"--monitor-new-items",
"new",
"--quality-profile-id",
"1",
"--metadata-profile-id",
"1",
"--root-folder-path",
"/nfs/test",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "edit", "indexer"]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-rss",
"--disable-rss",
]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-automatic-search",
"--disable-automatic-search",
]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--enable-interactive-search",
"--disable-interactive-search",
]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--clear-tags",
]);
assert_err!(&result);
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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
flag,
]);
assert_err!(&result);
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 = LidarrEditCommand::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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--name",
"Test",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_tag_argument_is_repeatable() {
let expected_args = LidarrEditCommand::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",
"lidarr",
"edit",
"indexer",
"--indexer-id",
"1",
"--tag",
"1",
"--tag",
"2",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args);
}
#[test]
fn test_edit_indexer_all_arguments_defined() {
let expected_args = LidarrEditCommand::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",
"lidarr",
"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_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
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::models::servarr_models::{EditIndexerParams, IndexerSettings};
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler},
},
models::{
Serdeable,
lidarr_models::{EditArtistParams, LidarrSerdeable, NewItemMonitorType},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_edit_all_indexer_settings_command() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 12345,
minimum_age: 11,
retention: 29,
rss_sync_interval: 60,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(
IndexerSettings {
id: 5,
maximum_size: 22222,
minimum_age: 34,
retention: 48,
rss_sync_interval: 90,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(12345),
minimum_age: Some(11),
retention: Some(29),
rss_sync_interval: Some(60),
};
let result = LidarrEditCommandHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(true),
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: true,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::New),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_handles_disable_monitoring_flag_properly() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: Some(false),
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: true,
monitor_new_items: Some(NewItemMonitorType::None),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_edit_artist_command_no_monitoring_boolean_flags_returns_none_value() {
let expected_edit_artist_params = EditArtistParams {
artist_id: 1,
monitored: None,
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tags: Some(vec![1, 2]),
tag_input_string: None,
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditArtist(expected_edit_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_artist_command = LidarrEditCommand::Artist {
artist_id: 1,
enable_monitoring: false,
disable_monitoring: false,
monitor_new_items: Some(NewItemMonitorType::All),
quality_profile_id: Some(1),
metadata_profile_id: Some(1),
root_folder_path: Some("/nfs/test".to_owned()),
tag: Some(vec![1, 2]),
clear_tags: false,
};
let result = LidarrEditCommandHandler::with(&app_arc, edit_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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]),
tag_input_string: None,
priority: Some(25),
clear_tags: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditIndexer(expected_edit_indexer_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = LidarrEditCommand::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 =
LidarrEditCommandHandler::with(&app_arc, edit_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+138
View File
@@ -0,0 +1,138 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "get_command_handler_tests.rs"]
mod get_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrGetCommand {
#[command(about = "Get detailed information for the album with the given ID")]
AlbumDetails {
#[arg(
long,
help = "The Lidarr ID of the album whose details you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "Get the shared settings for all indexers")]
AllIndexerSettings,
#[command(about = "Get detailed information for the artist with the given ID")]
ArtistDetails {
#[arg(
long,
help = "The Lidarr ID of the artist whose details you wish to fetch",
required = true
)]
artist_id: i64,
},
#[command(about = "Fetch the host config for your Lidarr instance")]
HostConfig,
#[command(about = "Fetch the security config for your Lidarr instance")]
SecurityConfig,
#[command(about = "Get the system status")]
SystemStatus,
#[command(about = "Get detailed information for the track with the given ID")]
TrackDetails {
#[arg(
long,
help = "The Lidarr ID of the track whose details you wish to fetch",
required = true
)]
track_id: i64,
},
}
impl From<LidarrGetCommand> for Command {
fn from(value: LidarrGetCommand) -> Self {
Command::Lidarr(LidarrCommand::Get(value))
}
}
pub(super) struct LidarrGetCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrGetCommand> for LidarrGetCommandHandler<'a, 'b> {
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrGetCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrGetCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrGetCommand::AlbumDetails { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbumDetails(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::AllIndexerSettings => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAllIndexerSettings.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::ArtistDetails { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetArtistDetails(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::HostConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHostConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SecurityConfig => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetSecurityConfig.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::SystemStatus => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetStatus.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrGetCommand::TrackDetails { track_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTrackDetails(track_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+329
View File
@@ -0,0 +1,329 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, get_command_handler::LidarrGetCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_get_command_from() {
let command = LidarrGetCommand::SystemStatus;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Get(command)));
}
mod cli {
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_album_details_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "album-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"album-details",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_all_indexer_settings_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "all-indexer-settings"]);
assert_ok!(&result);
}
#[test]
fn test_artist_details_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "artist-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_artist_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"artist-details",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_host_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "host-config"]);
assert_ok!(&result);
}
#[test]
fn test_security_config_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "security-config"]);
assert_ok!(&result);
}
#[test]
fn test_system_status_has_no_arg_requirements() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "system-status"]);
assert_ok!(&result);
}
#[test]
fn test_track_details_requires_track_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "get", "track-details"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_track_details_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"get",
"track-details",
"--track-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler},
},
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_get_album_details_command() {
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumDetails(expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_album_details_command = LidarrGetCommand::AlbumDetails { album_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_album_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_all_indexer_settings_command = LidarrGetCommand::AllIndexerSettings;
let result = LidarrGetCommandHandler::with(
&app_arc,
get_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_artist_details_command() {
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetArtistDetails(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_artist_details_command = LidarrGetCommand::ArtistDetails { artist_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_artist_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_host_config_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetHostConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_host_config_command = LidarrGetCommand::HostConfig;
let result =
LidarrGetCommandHandler::with(&app_arc, get_host_config_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_security_config_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetSecurityConfig.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_security_config_command = LidarrGetCommand::SecurityConfig;
let result =
LidarrGetCommandHandler::with(&app_arc, get_security_config_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_system_status_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrGetCommand::SystemStatus;
let result =
LidarrGetCommandHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_get_track_details_command() {
let expected_track_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackDetails(expected_track_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_track_details_command = LidarrGetCommand::TrackDetails { track_id: 1 };
let result =
LidarrGetCommandHandler::with(&app_arc, get_track_details_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
+773
View File
@@ -0,0 +1,773 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_command_from() {
let command = LidarrCommand::List(LidarrListCommand::Artists);
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(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", "lidarr", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_list_artists_has_no_arg_requirements() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artists"]);
assert_ok!(&result);
}
#[test]
fn test_lidarr_list_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_add_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "add"]);
assert_err!(&result);
}
#[test]
fn test_lidarr_delete_subcommand_requires_subcommand() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "delete"]);
assert_err!(&result);
}
#[test]
fn test_download_release_requires_guid() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--indexer-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_release_requires_indexer_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--guid",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_download_release_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"download-release",
"--guid",
"1",
"--indexer-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_toggle_artist_monitoring_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-artist-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_artist_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-artist-monitoring",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_toggle_album_monitoring_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "toggle-album-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_album_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"toggle-album-monitoring",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_search_new_artist_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "search-new-artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_search_new_artist_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"search-new-artist",
"--query",
"test query",
]);
assert_ok!(&result);
}
#[test]
fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "start-task"]);
assert_err!(&result);
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",
"lidarr",
"start-task",
"--task-name",
"test",
]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_start_task_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"start-task",
"--task-name",
"application-update-check",
]);
assert_ok!(&result);
}
#[test]
fn test_mark_history_item_as_failed_requires_history_item_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "mark-history-item-as-failed"]);
assert_err!(&result);
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",
"lidarr",
"mark-history-item-as-failed",
"--history-item-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_test_indexer_requires_indexer_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "test-indexer"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_test_indexer_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"test-indexer",
"--indexer-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::add_command_handler::LidarrAddCommand;
use crate::cli::lidarr::edit_command_handler::LidarrEditCommand;
use crate::cli::lidarr::get_command_handler::LidarrGetCommand;
use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand;
use crate::cli::lidarr::refresh_command_handler::LidarrRefreshCommand;
use crate::cli::lidarr::trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand;
use crate::models::lidarr_models::{BlocklistItem, BlocklistResponse, LidarrTaskName};
use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody};
use crate::{
app::App,
cli::{
CliCommandHandler,
lidarr::{
LidarrCliHandler, LidarrCommand, delete_command_handler::LidarrDeleteCommand,
list_command_handler::LidarrListCommand,
},
},
models::{
Serdeable,
lidarr_models::{Artist, DeleteParams, LidarrSerdeable},
},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_lidarr_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::<NetworkEvent>(
LidarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = LidarrCommand::Add(LidarrAddCommand::Tag {
name: expected_tag_name,
});
let result = LidarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_get_commands_to_the_get_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = LidarrCommand::Get(LidarrGetCommand::SystemStatus);
let result = LidarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_artist_params = DeleteParams {
id: 1,
delete_files: true,
add_import_list_exclusion: true,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DeleteArtist(expected_delete_artist_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_artist_command = LidarrCommand::Delete(LidarrDeleteCommand::Artist {
artist_id: 1,
delete_files_from_disk: true,
add_list_exclusion: true,
});
let result = LidarrCliHandler::with(&app_arc, delete_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_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::<NetworkEvent>(
LidarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::IndexerSettings(
IndexerSettings {
id: 1,
maximum_size: 2,
minimum_age: 2,
retention: 2,
rss_sync_interval: 2,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command =
LidarrCommand::Edit(LidarrEditCommand::AllIndexerSettings {
maximum_size: Some(1),
minimum_age: Some(1),
retention: Some(1),
rss_sync_interval: Some(1),
});
let result = LidarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_list_commands_to_the_list_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ListArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Artists(vec![
Artist::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_artists_command = LidarrCommand::List(LidarrListCommand::Artists);
let result = LidarrCliHandler::with(&app_arc, list_artists_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_refresh_commands_to_the_refresh_command_handler() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::UpdateAllArtists.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_artist_command = LidarrCommand::Refresh(LidarrRefreshCommand::AllArtists);
let result = LidarrCliHandler::with(&app_arc, refresh_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_manual_search_commands_to_the_manual_search_command_handler()
{
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDiscographyReleases(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_episode_search_command =
LidarrCommand::ManualSearch(LidarrManualSearchCommand::Discography { artist_id: 1 });
let result =
LidarrCliHandler::with(&app_arc, manual_episode_search_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_lidarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrCommand::TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand::Artist {
artist_id: 1,
});
let result = LidarrCliHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_clear_blocklist_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::BlocklistResponse(
BlocklistResponse {
records: vec![BlocklistItem::default()],
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::ClearBlocklist.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let claer_blocklist_command = LidarrCommand::ClearBlocklist;
let result = LidarrCliHandler::with(&app_arc, claer_blocklist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_download_release_command() {
let expected_release_download_body = ReleaseDownloadBody {
guid: "guid".to_owned(),
indexer_id: 1,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::DownloadRelease(expected_release_download_body).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let download_release_command = LidarrCommand::DownloadRelease {
guid: "guid".to_owned(),
indexer_id: 1,
};
let result = LidarrCliHandler::with(&app_arc, download_release_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_toggle_artist_monitoring_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::ToggleArtistMonitoring(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let toggle_artist_monitoring_command = LidarrCommand::ToggleArtistMonitoring { artist_id: 1 };
let result = LidarrCliHandler::with(
&app_arc,
toggle_artist_monitoring_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_search_new_artist_command() {
let expected_query = "test artist".to_owned();
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::SearchNewArtist(expected_query.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let search_new_artist_command = LidarrCommand::SearchNewArtist {
query: expected_query,
};
let result = LidarrCliHandler::with(&app_arc, search_new_artist_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_start_task_command() {
let expected_task_name = LidarrTaskName::ApplicationUpdateCheck;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::StartTask(expected_task_name).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let start_task_command = LidarrCommand::StartTask {
task_name: LidarrTaskName::ApplicationUpdateCheck,
};
let result = LidarrCliHandler::with(&app_arc, start_task_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::TestIndexer(expected_indexer_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_indexer_command = LidarrCommand::TestIndexer { indexer_id: 1 };
let result = LidarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_test_all_indexers_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::TestAllIndexers.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_all_indexers_command = LidarrCommand::TestAllIndexers;
let result = LidarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_mark_history_item_as_failed_command() {
let expected_history_item_id = 1i64;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let mark_history_item_as_failed_command = LidarrCommand::MarkHistoryItemAsFailed {
history_item_id: expected_history_item_id,
};
let result = LidarrCliHandler::with(
&app_arc,
mark_history_item_as_failed_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
+350
View File
@@ -0,0 +1,350 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use serde_json::json;
use tokio::sync::Mutex;
use super::LidarrCommand;
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrSerdeable};
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
#[cfg(test)]
#[path = "list_command_handler_tests.rs"]
mod list_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrListCommand {
#[command(about = "List all albums for the artist with the given ID")]
Albums {
#[arg(
long,
help = "The Lidarr ID of the artist whose albums you want to list",
required = true
)]
artist_id: i64,
},
#[command(
about = "Fetch all history events for the given album corresponding to the artist with the given ID."
)]
AlbumHistory {
#[arg(
long,
help = "The Lidarr artist ID of the artist whose history you wish to fetch and list",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The Lidarr album ID to fetch history events for",
required = true
)]
album_id: i64,
},
#[command(about = "Fetch all history events for the artist with the given ID")]
ArtistHistory {
#[arg(
long,
help = "The Lidarr ID of the artist whose history you wish to fetch",
required = true
)]
artist_id: i64,
},
#[command(about = "List all artists in your Lidarr library")]
Artists,
#[command(about = "List all items in the Lidarr blocklist")]
Blocklist,
#[command(about = "List disk space details for all provisioned root folders in Lidarr")]
DiskSpace,
#[command(about = "List all active downloads in Lidarr")]
Downloads {
#[arg(long, help = "How many downloads to fetch", default_value_t = 500)]
count: u64,
},
#[command(about = "Fetch all Lidarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Lidarr indexers")]
Indexers,
#[command(about = "Fetch Lidarr 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 Lidarr metadata profiles")]
MetadataProfiles,
#[command(about = "List all Lidarr quality profiles")]
QualityProfiles,
#[command(about = "List all queued events")]
QueuedEvents,
#[command(about = "List all root folders in Lidarr")]
RootFolders,
#[command(about = "List all Lidarr tags")]
Tags,
#[command(about = "List all Lidarr tasks")]
Tasks,
#[command(about = "Fetch all history events for the track with the given ID")]
TrackHistory {
#[arg(
long,
help = "The artist ID that the track belongs to",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The album ID that the track is a part of",
required = true
)]
album_id: i64,
#[arg(
long,
help = "The Lidarr ID of the track whose history you wish to fetch",
required = true
)]
track_id: i64,
},
#[command(
about = "List the tracks for the album that corresponds to the artist with the given ID"
)]
Tracks {
#[arg(
long,
help = "The Lidarr artist ID of the artist whose tracks you wish to fetch",
required = true
)]
artist_id: i64,
#[arg(
long,
help = "The Lidarr album ID whose tracks you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "List the track files for the album with the given ID")]
TrackFiles {
#[arg(
long,
help = "The Lidarr ID of the album whose track files you wish to fetch",
required = true
)]
album_id: i64,
},
#[command(about = "List all Lidarr updates")]
Updates,
}
impl From<LidarrListCommand> for Command {
fn from(value: LidarrListCommand) -> Self {
Command::Lidarr(LidarrCommand::List(value))
}
}
pub(super) struct LidarrListCommandHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrListCommand> for LidarrListCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrListCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrListCommandHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrListCommand::Albums { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbums(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::AlbumHistory {
artist_id,
album_id,
} => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetAlbumHistory(artist_id, album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::ArtistHistory { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetArtistHistory(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Artists => {
let resp = self
.network
.handle_network_event(LidarrEvent::ListArtists.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Blocklist => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::DiskSpace => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetDiskSpace.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Downloads { count } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetDownloads(count).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetHistory(items).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Indexers => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Logs {
events,
output_in_log_format,
} => {
let logs = self
.network
.handle_network_event(LidarrEvent::GetLogs(events).into())
.await?;
if output_in_log_format {
let log_lines = &self.app.lock().await.data.sonarr_data.logs.items;
serde_json::to_string_pretty(log_lines)?
} else {
serde_json::to_string_pretty(&logs)?
}
}
LidarrListCommand::MetadataProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetMetadataProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::QualityProfiles => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetQualityProfiles.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::QueuedEvents => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetQueuedEvents.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::RootFolders => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetRootFolders.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Tags => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTags.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Tasks => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTasks.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::TrackHistory {
artist_id,
album_id,
track_id,
} => {
match self
.network
.handle_network_event(LidarrEvent::GetTrackHistory(artist_id, album_id, track_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::LidarrHistoryItems(history_vec))) => {
let history_items_vec: Vec<LidarrHistoryItem> = history_vec
.into_iter()
.filter(|it| it.track_id == track_id)
.collect();
serde_json::to_string_pretty(&history_items_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
LidarrListCommand::Tracks {
artist_id,
album_id,
} => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTracks(artist_id, album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::TrackFiles { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetTrackFiles(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrListCommand::Updates => {
let resp = self
.network
.handle_network_event(LidarrEvent::GetUpdates.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,728 @@
#[cfg(test)]
mod tests {
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, list_command_handler::LidarrListCommand},
};
use clap::CommandFactory;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_list_command_from() {
let command = LidarrListCommand::Artists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::List(command)));
}
mod cli {
use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_list_commands_have_no_arg_requirements(
#[values(
"artists",
"blocklist",
"disk-space",
"indexers",
"metadata-profiles",
"quality-profiles",
"queued-events",
"tags",
"tasks",
"updates",
"root-folders"
)]
subcommand: &str,
) {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_list_albums_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "albums"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_albums_with_artist_id() {
let expected_args = LidarrListCommand::Albums { artist_id: 1 };
let result =
Cli::try_parse_from(["managarr", "lidarr", "list", "albums", "--artist-id", "1"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(album_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(album_command, expected_args);
}
#[test]
fn test_album_history_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_history_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_album_history_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"album-history",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_list_artist_history_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "artist-history"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_artist_history_success() {
let expected_args = LidarrListCommand::ArtistHistory { artist_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"artist-history",
"--artist-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(artist_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(artist_command, expected_args);
}
#[test]
fn test_list_downloads_count_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "downloads", "--count"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_downloads_default_values() {
let expected_args = LidarrListCommand::Downloads { count: 500 };
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "downloads"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(downloads_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(downloads_command, expected_args);
}
#[test]
fn test_list_history_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "history", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_history_default_values() {
let expected_args = LidarrListCommand::History { events: 500 };
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "history"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(history_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(history_command, expected_args);
}
#[test]
fn test_list_logs_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "logs", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_logs_default_values() {
let expected_args = LidarrListCommand::Logs {
events: 500,
output_in_log_format: false,
};
let result = Cli::try_parse_from(["managarr", "lidarr", "list", "logs"]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(logs_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(logs_command, expected_args);
}
#[test]
fn test_list_track_history_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--album-id",
"1",
"--track-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--track-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_requires_track_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_history_success() {
let expected_args = LidarrListCommand::TrackHistory {
artist_id: 1,
album_id: 1,
track_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"track-history",
"--artist-id",
"1",
"--album-id",
"1",
"--track-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(track_history_command))) =
result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(track_history_command, expected_args);
}
#[test]
fn test_list_tracks_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"tracks",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_tracks_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"list",
"tracks",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_tracks_success() {
let expected_args = LidarrListCommand::Tracks {
artist_id: 1,
album_id: 1,
};
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"tracks",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(tracks_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(tracks_command, expected_args);
}
#[test]
fn test_list_track_files_requires_album_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "list", "track-files"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_list_track_files_success() {
let expected_args = LidarrListCommand::TrackFiles { album_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"list",
"track-files",
"--album-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::List(track_files_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(track_files_command, expected_args);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use pretty_assertions::assert_str_eq;
use rstest::rstest;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::lidarr_history_item;
use crate::{
app::App,
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(LidarrListCommand::Artists, LidarrEvent::ListArtists)]
#[case(LidarrListCommand::Blocklist, LidarrEvent::GetBlocklist)]
#[case(LidarrListCommand::DiskSpace, LidarrEvent::GetDiskSpace)]
#[case(LidarrListCommand::Indexers, LidarrEvent::GetIndexers)]
#[case(LidarrListCommand::MetadataProfiles, LidarrEvent::GetMetadataProfiles)]
#[case(LidarrListCommand::QualityProfiles, LidarrEvent::GetQualityProfiles)]
#[case(LidarrListCommand::QueuedEvents, LidarrEvent::GetQueuedEvents)]
#[case(LidarrListCommand::RootFolders, LidarrEvent::GetRootFolders)]
#[case(LidarrListCommand::Tags, LidarrEvent::GetTags)]
#[case(LidarrListCommand::Tasks, LidarrEvent::GetTasks)]
#[case(LidarrListCommand::Updates, LidarrEvent::GetUpdates)]
#[tokio::test]
async fn test_handle_list_command(
#[case] list_command: LidarrListCommand,
#[case] expected_lidarr_event: LidarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_lidarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_albums_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(LidarrEvent::GetAlbums(1).into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_command = LidarrListCommand::Albums { artist_id: 1 };
let result = LidarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_list_album_history_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumHistory(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_album_history_command = LidarrListCommand::AlbumHistory {
artist_id: 1,
album_id: 1,
};
let result =
LidarrListCommandHandler::with(&app_arc, list_album_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_artist_history_command() {
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetArtistHistory(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_artist_history_command = LidarrListCommand::ArtistHistory { artist_id: 1 };
let result =
LidarrListCommandHandler::with(&app_arc, list_artist_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_downloads_command() {
let expected_count = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDownloads(expected_count).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_downloads_command = LidarrListCommand::Downloads { count: 1000 };
let result =
LidarrListCommandHandler::with(&app_arc, list_downloads_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::GetHistory(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_history_command = LidarrListCommand::History { events: 1000 };
let result =
LidarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
LidarrEvent::GetLogs(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_logs_command = LidarrListCommand::Logs {
events: 1000,
output_in_log_format: false,
};
let result = LidarrListCommandHandler::with(&app_arc, list_logs_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_track_history_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let expected_track_id = 5;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackHistory(expected_artist_id, expected_album_id, expected_track_id)
.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::LidarrHistoryItems(
vec![
lidarr_history_item(),
LidarrHistoryItem {
track_id: 2,
..lidarr_history_item()
},
],
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_track_history_command = LidarrListCommand::TrackHistory {
artist_id: expected_artist_id,
album_id: expected_album_id,
track_id: expected_track_id,
};
let result =
LidarrListCommandHandler::with(&app_arc, list_track_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&[lidarr_history_item()]).unwrap()
);
}
#[tokio::test]
async fn test_handle_list_tracks_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTracks(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_tracks_command = LidarrListCommand::Tracks {
artist_id: 1,
album_id: 1,
};
let result = LidarrListCommandHandler::with(&app_arc, list_tracks_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_track_files_command() {
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetTrackFiles(expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_track_files_command = LidarrListCommand::TrackFiles { album_id: 1 };
let result =
LidarrListCommandHandler::with(&app_arc, list_track_files_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
@@ -0,0 +1,118 @@
use crate::app::App;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::{CliCommandHandler, Command};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrRelease, LidarrSerdeable};
use crate::network::NetworkTrait;
use crate::network::lidarr_network::LidarrEvent;
use anyhow::Result;
use clap::Subcommand;
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
#[cfg(test)]
#[path = "manual_search_command_handler_tests.rs"]
mod manual_search_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrManualSearchCommand {
#[command(
about = "Trigger a manual search of releases for the given album corresponding to the artist with the given ID"
)]
Album {
#[arg(
long,
help = "The Lidarr ID of the artist whose releases you wish to fetch and list",
required = true
)]
artist_id: i64,
#[arg(long, help = "The Lidarr album ID to search for", required = true)]
album_id: i64,
},
#[command(
about = "Trigger a manual search of discography releases for the given artist corresponding to the artist with the given ID."
)]
Discography {
#[arg(
long,
help = "The Lidarr ID of the artist whose discography releases you wish to fetch and list",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrManualSearchCommand> for Command {
fn from(value: LidarrManualSearchCommand) -> Self {
Command::Lidarr(LidarrCommand::ManualSearch(value))
}
}
pub(super) struct LidarrManualSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrManualSearchCommand>
for LidarrManualSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrManualSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrManualSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrManualSearchCommand::Album {
artist_id,
album_id,
} => {
println!("Searching for album releases. This may take a minute...");
match self
.network
.handle_network_event(LidarrEvent::GetAlbumReleases(artist_id, album_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(releases_vec))) => {
let albums_vec: Vec<LidarrRelease> = releases_vec
.into_iter()
.filter(|release| !release.discography)
.collect();
serde_json::to_string_pretty(&albums_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
LidarrManualSearchCommand::Discography { artist_id } => {
println!("Searching for artist discography releases. This may take a minute...");
match self
.network
.handle_network_event(LidarrEvent::GetDiscographyReleases(artist_id).into())
.await
{
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(releases_vec))) => {
let discography_vec: Vec<LidarrRelease> = releases_vec
.into_iter()
.filter(|release| release.discography)
.collect();
serde_json::to_string_pretty(&discography_vec)?
}
Err(e) => return Err(e),
_ => serde_json::to_string_pretty(&json!({"message": "Failed to parse response"}))?,
}
}
};
Ok(result)
}
}
@@ -0,0 +1,207 @@
#[cfg(test)]
mod tests {
use crate::cli::Command;
use crate::cli::lidarr::LidarrCommand;
use crate::cli::lidarr::manual_search_command_handler::LidarrManualSearchCommand;
use pretty_assertions::assert_eq;
#[test]
fn test_lidarr_manual_search_command_from() {
let command = LidarrManualSearchCommand::Discography { artist_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Lidarr(LidarrCommand::ManualSearch(command))
);
}
mod cli {
use crate::Cli;
use clap::CommandFactory;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_manual_album_search_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--album-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_album_search_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--artist-id",
"1",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_album_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"album",
"--artist-id",
"1",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_manual_discography_search_requires_artist_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "manual-search", "discography"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_manual_discography_search_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"manual-search",
"discography",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use crate::app::App;
use crate::cli::CliCommandHandler;
use crate::cli::lidarr::manual_search_command_handler::{
LidarrManualSearchCommand, LidarrManualSearchCommandHandler,
};
use crate::models::Serdeable;
use crate::models::lidarr_models::{LidarrRelease, LidarrSerdeable};
use crate::network::lidarr_network::LidarrEvent;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
torrent_release, usenet_release,
};
use crate::network::{MockNetworkTrait, NetworkEvent};
use mockall::predicate::eq;
use pretty_assertions::assert_str_eq;
use std::sync::Arc;
use tokio::sync::Mutex;
#[tokio::test]
async fn test_manual_album_search_command() {
let expected_releases = [torrent_release()];
let expected_artist_id = 1;
let expected_album_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetAlbumReleases(expected_artist_id, expected_album_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(vec![
torrent_release(),
LidarrRelease {
discography: true,
..usenet_release()
},
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_album_search_command = LidarrManualSearchCommand::Album {
artist_id: 1,
album_id: 1,
};
let result = LidarrManualSearchCommandHandler::with(
&app_arc,
manual_album_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&expected_releases).unwrap()
);
}
#[tokio::test]
async fn test_manual_discography_search_command() {
let expected_releases = [LidarrRelease {
discography: true,
..usenet_release()
}];
let expected_artist_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::GetDiscographyReleases(expected_artist_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Releases(vec![
torrent_release(),
LidarrRelease {
discography: true,
..usenet_release()
},
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_discography_search_command =
LidarrManualSearchCommand::Discography { artist_id: 1 };
let result = LidarrManualSearchCommandHandler::with(
&app_arc,
manual_discography_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
assert_str_eq!(
result.unwrap(),
serde_json::to_string_pretty(&expected_releases).unwrap()
);
}
}
}
+296
View File
@@ -0,0 +1,296 @@
use std::sync::Arc;
use add_command_handler::{LidarrAddCommand, LidarrAddCommandHandler};
use anyhow::Result;
use clap::Subcommand;
use delete_command_handler::{LidarrDeleteCommand, LidarrDeleteCommandHandler};
use edit_command_handler::{LidarrEditCommand, LidarrEditCommandHandler};
use get_command_handler::{LidarrGetCommand, LidarrGetCommandHandler};
use list_command_handler::{LidarrListCommand, LidarrListCommandHandler};
use refresh_command_handler::{LidarrRefreshCommand, LidarrRefreshCommandHandler};
use serde_json::json;
use tokio::sync::Mutex;
use trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use super::{CliCommandHandler, Command};
use crate::cli::lidarr::manual_search_command_handler::{
LidarrManualSearchCommand, LidarrManualSearchCommandHandler,
};
use crate::models::lidarr_models::LidarrTaskName;
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::network::lidarr_network::LidarrEvent;
use crate::{app::App, network::NetworkTrait};
mod add_command_handler;
mod delete_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 = "lidarr_command_tests.rs"]
mod lidarr_command_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrCommand {
#[command(
subcommand,
about = "Commands to add or create new resources within your Lidarr instance"
)]
Add(LidarrAddCommand),
#[command(
subcommand,
about = "Commands to delete resources from your Lidarr instance"
)]
Delete(LidarrDeleteCommand),
#[command(
subcommand,
about = "Commands to edit resources in your Lidarr instance"
)]
Edit(LidarrEditCommand),
#[command(
subcommand,
about = "Commands to fetch details of the resources in your Lidarr instance"
)]
Get(LidarrGetCommand),
#[command(
subcommand,
about = "Commands to list attributes from your Lidarr instance"
)]
List(LidarrListCommand),
#[command(
subcommand,
about = "Commands to refresh the data in your Lidarr instance"
)]
Refresh(LidarrRefreshCommand),
#[command(subcommand, about = "Commands to manually search for releases")]
ManualSearch(LidarrManualSearchCommand),
#[command(
subcommand,
about = "Commands to trigger automatic searches for releases of different resources in your Lidarr instance"
)]
TriggerAutomaticSearch(LidarrTriggerAutomaticSearchCommand),
#[command(about = "Clear the Lidarr blocklist")]
ClearBlocklist,
#[command(about = "Manually download the given release")]
DownloadRelease {
#[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,
},
#[command(about = "Mark the Lidarr history item with the given ID as 'failed'")]
MarkHistoryItemAsFailed {
#[arg(
long,
help = "The Lidarr ID of the history item you wish to mark as 'failed'",
required = true
)]
history_item_id: i64,
},
#[command(about = "Search for a new artist to add to Lidarr")]
SearchNewArtist {
#[arg(
long,
help = "The name of the artist you want to search for",
required = true
)]
query: String,
},
#[command(about = "Start the specified Lidarr task")]
StartTask {
#[arg(
long,
help = "The name of the task to trigger",
value_enum,
required = true
)]
task_name: LidarrTaskName,
},
#[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 Lidarr indexers")]
TestAllIndexers,
#[command(
about = "Toggle monitoring for the specified album corresponding to the given album ID"
)]
ToggleAlbumMonitoring {
#[arg(
long,
help = "The Lidarr ID of the album to toggle monitoring on",
required = true
)]
album_id: i64,
},
#[command(
about = "Toggle monitoring for the specified artist corresponding to the given artist ID"
)]
ToggleArtistMonitoring {
#[arg(
long,
help = "The Lidarr ID of the artist to toggle monitoring on",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrCommand> for Command {
fn from(lidarr_command: LidarrCommand) -> Command {
Command::Lidarr(lidarr_command)
}
}
pub(super) struct LidarrCliHandler<'a, 'b> {
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrCommand> for LidarrCliHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: LidarrCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrCliHandler {
app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrCommand::Add(add_command) => {
LidarrAddCommandHandler::with(self.app, add_command, self.network)
.handle()
.await?
}
LidarrCommand::Delete(delete_command) => {
LidarrDeleteCommandHandler::with(self.app, delete_command, self.network)
.handle()
.await?
}
LidarrCommand::Edit(edit_command) => {
LidarrEditCommandHandler::with(self.app, edit_command, self.network)
.handle()
.await?
}
LidarrCommand::Get(get_command) => {
LidarrGetCommandHandler::with(self.app, get_command, self.network)
.handle()
.await?
}
LidarrCommand::List(list_command) => {
LidarrListCommandHandler::with(self.app, list_command, self.network)
.handle()
.await?
}
LidarrCommand::Refresh(refresh_command) => {
LidarrRefreshCommandHandler::with(self.app, refresh_command, self.network)
.handle()
.await?
}
LidarrCommand::ManualSearch(manual_search_command) => {
LidarrManualSearchCommandHandler::with(self.app, manual_search_command, self.network)
.handle()
.await?
}
LidarrCommand::TriggerAutomaticSearch(trigger_automatic_search_command) => {
LidarrTriggerAutomaticSearchCommandHandler::with(
self.app,
trigger_automatic_search_command,
self.network,
)
.handle()
.await?
}
LidarrCommand::ClearBlocklist => {
self
.network
.handle_network_event(LidarrEvent::GetBlocklist.into())
.await?;
let resp = self
.network
.handle_network_event(LidarrEvent::ClearBlocklist.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::DownloadRelease { guid, indexer_id } => {
let params = ReleaseDownloadBody { guid, indexer_id };
let resp = self
.network
.handle_network_event(LidarrEvent::DownloadRelease(params).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::MarkHistoryItemAsFailed { history_item_id } => {
let _ = self
.network
.handle_network_event(LidarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
serde_json::to_string_pretty(&json!({"message": "Lidarr history item marked as 'failed'"}))?
}
LidarrCommand::SearchNewArtist { query } => {
let resp = self
.network
.handle_network_event(LidarrEvent::SearchNewArtist(query).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::StartTask { task_name } => {
let resp = self
.network
.handle_network_event(LidarrEvent::StartTask(task_name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::TestIndexer { indexer_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TestIndexer(indexer_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::TestAllIndexers => {
println!("Testing all Lidarr indexers. This may take a minute...");
let resp = self
.network
.handle_network_event(LidarrEvent::TestAllIndexers.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleAlbumMonitoring { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleAlbumMonitoring(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrCommand::ToggleArtistMonitoring { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::ToggleArtistMonitoring(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
+89
View File
@@ -0,0 +1,89 @@
use std::sync::Arc;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[cfg(test)]
#[path = "refresh_command_handler_tests.rs"]
mod refresh_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum LidarrRefreshCommand {
#[command(about = "Refresh all artist data for all artists in your Lidarr library")]
AllArtists,
#[command(about = "Refresh artist data and scan disk for the artist with the given ID")]
Artist {
#[arg(
long,
help = "The ID of the artist to refresh information on and to scan the disk for",
required = true
)]
artist_id: i64,
},
#[command(about = "Refresh all downloads in Lidarr")]
Downloads,
}
impl From<LidarrRefreshCommand> for Command {
fn from(value: LidarrRefreshCommand) -> Self {
Command::Lidarr(LidarrCommand::Refresh(value))
}
}
pub(super) struct LidarrRefreshCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrRefreshCommand>
for LidarrRefreshCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrRefreshCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrRefreshCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> anyhow::Result<String> {
let result = match self.command {
LidarrRefreshCommand::AllArtists => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAllArtists.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrRefreshCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateAndScanArtist(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrRefreshCommand::Downloads => {
let resp = self
.network
.handle_network_event(LidarrEvent::UpdateDownloads.into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,138 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{LidarrCommand, refresh_command_handler::LidarrRefreshCommand},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_refresh_command_from() {
let command = LidarrRefreshCommand::AllArtists;
let result = Command::from(command.clone());
assert_eq!(result, Command::Lidarr(LidarrCommand::Refresh(command)));
}
mod cli {
use super::*;
use clap::{Parser, error::ErrorKind};
use pretty_assertions::assert_eq;
use rstest::rstest;
#[rstest]
fn test_refresh_commands_have_no_arg_requirements(
#[values("all-artists", "downloads")] subcommand: &str,
) {
let result =
Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", subcommand]);
assert_ok!(&result);
}
#[test]
fn test_refresh_artist_requires_artist_id() {
let result = Cli::command().try_get_matches_from(["managarr", "lidarr", "refresh", "artist"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_refresh_artist_with_artist_id() {
let expected_args = LidarrRefreshCommand::Artist { artist_id: 1 };
let result = Cli::try_parse_from([
"managarr",
"lidarr",
"refresh",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
let Some(Command::Lidarr(LidarrCommand::Refresh(refresh_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(refresh_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::{app::App, cli::lidarr::refresh_command_handler::LidarrRefreshCommandHandler};
use crate::{
cli::{CliCommandHandler, lidarr::refresh_command_handler::LidarrRefreshCommand},
network::lidarr_network::LidarrEvent,
};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent},
};
#[rstest]
#[case(LidarrRefreshCommand::AllArtists, LidarrEvent::UpdateAllArtists)]
#[case(LidarrRefreshCommand::Downloads, LidarrEvent::UpdateDownloads)]
#[tokio::test]
async fn test_handle_refresh_command(
#[case] refresh_command: LidarrRefreshCommand,
#[case] expected_sonarr_event: LidarrEvent,
) {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(expected_sonarr_event.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_refresh_artist_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::UpdateAndScanArtist(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_command = LidarrRefreshCommand::Artist { artist_id: 1 };
let result = LidarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
}
}
@@ -0,0 +1,88 @@
use std::sync::Arc;
use anyhow::Result;
use clap::Subcommand;
use tokio::sync::Mutex;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
network::{NetworkTrait, lidarr_network::LidarrEvent},
};
use super::LidarrCommand;
#[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 LidarrTriggerAutomaticSearchCommand {
#[command(about = "Trigger an automatic search for the album with the specified ID")]
Album {
#[arg(
long,
help = "The Lidarr ID of the album you want to trigger an automatic search for",
required = true
)]
album_id: i64,
},
#[command(about = "Trigger an automatic search for the artist with the specified ID")]
Artist {
#[arg(
long,
help = "The ID of the artist you want to trigger an automatic search for",
required = true
)]
artist_id: i64,
},
}
impl From<LidarrTriggerAutomaticSearchCommand> for Command {
fn from(value: LidarrTriggerAutomaticSearchCommand) -> Self {
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(value))
}
}
pub(super) struct LidarrTriggerAutomaticSearchCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, LidarrTriggerAutomaticSearchCommand>
for LidarrTriggerAutomaticSearchCommandHandler<'a, 'b>
{
fn with(
_app: &'a Arc<Mutex<App<'b>>>,
command: LidarrTriggerAutomaticSearchCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
LidarrTriggerAutomaticSearchCommandHandler {
_app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
LidarrTriggerAutomaticSearchCommand::Album { album_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TriggerAutomaticAlbumSearch(album_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
LidarrTriggerAutomaticSearchCommand::Artist { artist_id } => {
let resp = self
.network
.handle_network_event(LidarrEvent::TriggerAutomaticArtistSearch(artist_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
@@ -0,0 +1,166 @@
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use crate::Cli;
use crate::cli::{
Command,
lidarr::{
LidarrCommand, trigger_automatic_search_command_handler::LidarrTriggerAutomaticSearchCommand,
},
};
use clap::CommandFactory;
#[test]
fn test_lidarr_trigger_automatic_search_command_from() {
let command = LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = Command::from(command.clone());
assert_eq!(
result,
Command::Lidarr(LidarrCommand::TriggerAutomaticSearch(command))
);
}
mod cli {
use super::*;
use clap::error::ErrorKind;
use pretty_assertions::assert_eq;
#[test]
fn test_trigger_automatic_album_search_requires_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"album",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_album_search_with_album_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"album",
"--album-id",
"1",
]);
assert_ok!(&result);
}
#[test]
fn test_trigger_automatic_artist_search_requires_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_trigger_automatic_artist_search_with_artist_id() {
let result = Cli::command().try_get_matches_from([
"managarr",
"lidarr",
"trigger-automatic-search",
"artist",
"--artist-id",
"1",
]);
assert_ok!(&result);
}
}
mod handler {
use std::sync::Arc;
use mockall::predicate::eq;
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::lidarr::trigger_automatic_search_command_handler::{
LidarrTriggerAutomaticSearchCommand, LidarrTriggerAutomaticSearchCommandHandler,
};
use crate::{app::App, cli::CliCommandHandler};
use crate::{
models::{Serdeable, lidarr_models::LidarrSerdeable},
network::{MockNetworkTrait, NetworkEvent, lidarr_network::LidarrEvent},
};
#[tokio::test]
async fn test_handle_trigger_automatic_album_search_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticAlbumSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrTriggerAutomaticSearchCommand::Album { album_id: 1 };
let result = LidarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_trigger_automatic_artist_search_command() {
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
LidarrEvent::TriggerAutomaticArtistSearch(1).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Lidarr(LidarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
LidarrTriggerAutomaticSearchCommand::Artist { artist_id: 1 };
let result = LidarrTriggerAutomaticSearchCommandHandler::with(
&app_arc,
trigger_automatic_search_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
}
}
+28 -1
View File
@@ -1,15 +1,20 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{command, Subcommand}; use clap::Subcommand;
use clap_complete::Shell; use clap_complete::Shell;
use indoc::indoc;
use lidarr::{LidarrCliHandler, LidarrCommand};
use radarr::{RadarrCliHandler, RadarrCommand}; use radarr::{RadarrCliHandler, RadarrCommand};
use readarr::{ReadarrCliHandler, ReadarrCommand};
use sonarr::{SonarrCliHandler, SonarrCommand}; use sonarr::{SonarrCliHandler, SonarrCommand};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{app::App, network::NetworkTrait}; use crate::{app::App, network::NetworkTrait};
pub mod lidarr;
pub mod radarr; pub mod radarr;
pub mod readarr;
pub mod sonarr; pub mod sonarr;
#[cfg(test)] #[cfg(test)]
@@ -24,6 +29,12 @@ pub enum Command {
#[command(subcommand, about = "Commands for manging your Sonarr instance")] #[command(subcommand, about = "Commands for manging your Sonarr instance")]
Sonarr(SonarrCommand), Sonarr(SonarrCommand),
#[command(subcommand, about = "Commands for manging your Lidarr instance")]
Lidarr(LidarrCommand),
#[command(subcommand, about = "Commands for managing your Readarr instance")]
Readarr(ReadarrCommand),
#[command( #[command(
arg_required_else_help = true, arg_required_else_help = true,
about = "Generate shell completions for the Managarr CLI" about = "Generate shell completions for the Managarr CLI"
@@ -38,6 +49,12 @@ pub enum Command {
#[arg(long, help = "Disable colored log output")] #[arg(long, help = "Disable colored log output")]
no_color: bool, no_color: bool,
}, },
#[command(about = indoc!{"
Print the full path to the default configuration file.
This file can be changed to another location using the '--config-file' flag
"})]
ConfigPath,
} }
pub trait CliCommandHandler<'a, 'b, T: Into<Command>> { pub trait CliCommandHandler<'a, 'b, T: Into<Command>> {
@@ -61,6 +78,16 @@ pub(crate) async fn handle_command(
.handle() .handle()
.await? .await?
} }
Command::Lidarr(lidarr_command) => {
LidarrCliHandler::with(app, lidarr_command, network)
.handle()
.await?
}
Command::Readarr(readarr_command) => {
ReadarrCliHandler::with(app, readarr_command, network)
.handle()
.await?
}
_ => String::new(), _ => String::new(),
}; };
+4 -4
View File
@@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{arg, command, ArgAction, Subcommand}; use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex; use tokio::sync::Mutex;
use super::RadarrCommand; use super::RadarrCommand;
@@ -10,7 +10,7 @@ use crate::{
app::App, app::App,
cli::{CliCommandHandler, Command}, cli::{CliCommandHandler, Command},
models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor}, models::radarr_models::{AddMovieBody, AddMovieOptions, MinimumAvailability, MovieMonitor},
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
#[cfg(test)] #[cfg(test)]
@@ -122,12 +122,12 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrAddCommand> for RadarrAddCommandHan
title: String::new(), title: String::new(),
root_folder_path, root_folder_path,
quality_profile_id, quality_profile_id,
minimum_availability: minimum_availability.to_string(), minimum_availability,
monitored: !disable_monitoring, monitored: !disable_monitoring,
tags, tags,
tag_input_string: None, tag_input_string: None,
add_options: AddMovieOptions { add_options: AddMovieOptions {
monitor: monitor.to_string(), monitor,
search_for_movie: !no_search_for_movie, search_for_movie: !no_search_for_movie,
}, },
}; };
+44 -39
View File
@@ -1,14 +1,14 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use clap::{error::ErrorKind, CommandFactory, Parser}; use clap::{CommandFactory, Parser, error::ErrorKind};
use crate::{ use crate::{
Cli,
cli::{ cli::{
radarr::{add_command_handler::RadarrAddCommand, RadarrCommand},
Command, Command,
radarr::{RadarrCommand, add_command_handler::RadarrAddCommand},
}, },
models::radarr_models::{MinimumAvailability, MovieMonitor}, models::radarr_models::{MinimumAvailability, MovieMonitor},
Cli,
}; };
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
@@ -32,7 +32,7 @@ mod tests {
fn test_add_movie_requires_arguments() { fn test_add_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "movie"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "movie"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -52,7 +52,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -72,7 +72,7 @@ mod tests {
"/test", "/test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -92,7 +92,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -117,7 +117,7 @@ mod tests {
flag, flag,
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -136,7 +136,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -156,7 +156,7 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -177,7 +177,7 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -207,11 +207,12 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args); assert_eq!(add_command, expected_args);
} }
}
#[test] #[test]
fn test_add_movie_tags_is_repeatable() { fn test_add_movie_tags_is_repeatable() {
@@ -243,11 +244,12 @@ mod tests {
"2", "2",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args); assert_eq!(add_command, expected_args);
} }
}
#[test] #[test]
fn test_add_movie_all_args_defined() { fn test_add_movie_all_args_defined() {
@@ -285,18 +287,19 @@ mod tests {
"--no-search-for-movie", "--no-search-for-movie",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args); assert_eq!(add_command, expected_args);
} }
}
#[test] #[test]
fn test_add_root_folder_requires_arguments() { fn test_add_root_folder_requires_arguments() {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "add", "root-folder"]); Cli::command().try_get_matches_from(["managarr", "radarr", "add", "root-folder"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -318,18 +321,19 @@ mod tests {
"/nfs/test", "/nfs/test",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args); assert_eq!(add_command, expected_args);
} }
}
#[test] #[test]
fn test_add_tag_requires_arguments() { fn test_add_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "tag"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "add", "tag"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -344,25 +348,26 @@ mod tests {
let result = Cli::try_parse_from(["managarr", "radarr", "add", "tag", "--name", "test"]); let result = Cli::try_parse_from(["managarr", "radarr", "add", "tag", "--name", "test"]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Add(add_command))) = result.unwrap().command else {
panic!("Unexpected command type")
};
assert_eq!(add_command, expected_args); assert_eq!(add_command, expected_args);
} }
} }
}
mod handler { mod handler {
use std::sync::Arc; use std::sync::Arc;
use crate::{ use crate::{
app::App, app::App,
cli::{radarr::add_command_handler::RadarrAddCommandHandler, CliCommandHandler}, cli::{CliCommandHandler, radarr::add_command_handler::RadarrAddCommandHandler},
models::{ models::{
radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable},
Serdeable, Serdeable,
radarr_models::{AddMovieBody, AddMovieOptions, RadarrSerdeable},
}, },
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
use super::*; use super::*;
@@ -379,12 +384,12 @@ mod tests {
title: String::new(), title: String::new(),
root_folder_path: "/test".to_owned(), root_folder_path: "/test".to_owned(),
quality_profile_id: 1, quality_profile_id: 1,
minimum_availability: "released".to_owned(), minimum_availability: MinimumAvailability::Released,
monitored: false, monitored: false,
tags: vec![1, 2], tags: vec![1, 2],
tag_input_string: None, tag_input_string: None,
add_options: AddMovieOptions { add_options: AddMovieOptions {
monitor: "movieAndCollection".to_owned(), monitor: MovieMonitor::MovieAndCollection,
search_for_movie: false, search_for_movie: false,
}, },
}; };
@@ -400,7 +405,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_movie_command = RadarrAddCommand::Movie { let add_movie_command = RadarrAddCommand::Movie {
tmdb_id: 1, tmdb_id: 1,
root_folder_path: "/test".to_owned(), root_folder_path: "/test".to_owned(),
@@ -416,7 +421,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -437,7 +442,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_root_folder_command = RadarrAddCommand::RootFolder { let add_root_folder_command = RadarrAddCommand::RootFolder {
root_folder_path: expected_root_folder_path, root_folder_path: expected_root_folder_path,
}; };
@@ -447,7 +452,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -465,7 +470,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = RadarrAddCommand::Tag { let add_tag_command = RadarrAddCommand::Tag {
name: expected_tag_name, name: expected_tag_name,
}; };
@@ -474,7 +479,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ use crate::{
app::App, app::App,
cli::{CliCommandHandler, Command}, cli::{CliCommandHandler, Command},
models::radarr_models::DeleteMovieParams, models::radarr_models::DeleteMovieParams,
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
use super::RadarrCommand; use super::RadarrCommand;
+61 -54
View File
@@ -1,13 +1,13 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
cli::{
radarr::{delete_command_handler::RadarrDeleteCommand, RadarrCommand},
Command,
},
Cli, Cli,
cli::{
Command,
radarr::{RadarrCommand, delete_command_handler::RadarrDeleteCommand},
},
}; };
use clap::{error::ErrorKind, CommandFactory, Parser}; use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -30,7 +30,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "blocklist-item"]); Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "blocklist-item"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -52,20 +52,21 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_download_requires_arguments() { fn test_delete_download_requires_arguments() {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "download"]); Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "download"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -85,19 +86,20 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_indexer_requires_arguments() { fn test_delete_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "indexer"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "indexer"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -117,19 +119,20 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_movie_requires_arguments() { fn test_delete_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "movie"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "movie"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -147,13 +150,14 @@ mod tests {
let result = let result =
Cli::try_parse_from(["managarr", "radarr", "delete", "movie", "--movie-id", "1"]); Cli::try_parse_from(["managarr", "radarr", "delete", "movie", "--movie-id", "1"]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_movie_all_args_defined() { fn test_delete_movie_all_args_defined() {
@@ -174,20 +178,21 @@ mod tests {
"--add-list-exclusion", "--add-list-exclusion",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_root_folder_requires_arguments() { fn test_delete_root_folder_requires_arguments() {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "root-folder"]); Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "root-folder"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -207,19 +212,20 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
}
#[test] #[test]
fn test_delete_tag_requires_arguments() { fn test_delete_tag_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "tag"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "delete", "tag"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -232,14 +238,15 @@ mod tests {
let result = Cli::try_parse_from(["managarr", "radarr", "delete", "tag", "--tag-id", "1"]); let result = Cli::try_parse_from(["managarr", "radarr", "delete", "tag", "--tag-id", "1"]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command let Some(Command::Radarr(RadarrCommand::Delete(delete_command))) = result.unwrap().command
{ else {
panic!("Unexpected command type");
};
assert_eq!(delete_command, expected_args); assert_eq!(delete_command, expected_args);
} }
} }
}
mod handler { mod handler {
use std::sync::Arc; use std::sync::Arc;
@@ -251,14 +258,14 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::{ cli::{
radarr::delete_command_handler::{RadarrDeleteCommand, RadarrDeleteCommandHandler},
CliCommandHandler, CliCommandHandler,
radarr::delete_command_handler::{RadarrDeleteCommand, RadarrDeleteCommandHandler},
}, },
models::{ models::{
radarr_models::{DeleteMovieParams, RadarrSerdeable},
Serdeable, Serdeable,
radarr_models::{DeleteMovieParams, RadarrSerdeable},
}, },
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[tokio::test] #[tokio::test]
@@ -276,7 +283,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_blocklist_item_command = RadarrDeleteCommand::BlocklistItem { let delete_blocklist_item_command = RadarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1, blocklist_item_id: 1,
}; };
@@ -289,7 +296,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -307,7 +314,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_download_command = RadarrDeleteCommand::Download { download_id: 1 }; let delete_download_command = RadarrDeleteCommand::Download { download_id: 1 };
let result = let result =
@@ -315,7 +322,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -333,7 +340,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_indexer_command = RadarrDeleteCommand::Indexer { indexer_id: 1 }; let delete_indexer_command = RadarrDeleteCommand::Indexer { indexer_id: 1 };
let result = let result =
@@ -341,7 +348,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -363,7 +370,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_movie_command = RadarrDeleteCommand::Movie { let delete_movie_command = RadarrDeleteCommand::Movie {
movie_id: 1, movie_id: 1,
delete_files_from_disk: true, delete_files_from_disk: true,
@@ -375,7 +382,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -393,7 +400,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_root_folder_command = RadarrDeleteCommand::RootFolder { root_folder_id: 1 }; let delete_root_folder_command = RadarrDeleteCommand::RootFolder { root_folder_id: 1 };
let result = let result =
@@ -401,7 +408,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -419,7 +426,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_tag_command = RadarrDeleteCommand::Tag { tag_id: 1 }; let delete_tag_command = RadarrDeleteCommand::Tag { tag_id: 1 };
let result = let result =
@@ -427,7 +434,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+4 -10
View File
@@ -6,15 +6,15 @@ use tokio::sync::Mutex;
use crate::{ use crate::{
app::App, app::App,
cli::{mutex_flags_or_default, mutex_flags_or_option, CliCommandHandler, Command}, cli::{CliCommandHandler, Command, mutex_flags_or_default, mutex_flags_or_option},
models::{ models::{
Serdeable,
radarr_models::{ radarr_models::{
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable, EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, RadarrSerdeable,
}, },
servarr_models::EditIndexerParams, servarr_models::EditIndexerParams,
Serdeable,
}, },
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
use super::RadarrCommand; use super::RadarrCommand;
@@ -379,13 +379,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrEditCommand> for RadarrEditCommandH
rss_sync_interval: rss_sync_interval rss_sync_interval: rss_sync_interval
.unwrap_or(previous_indexer_settings.rss_sync_interval), .unwrap_or(previous_indexer_settings.rss_sync_interval),
whitelisted_hardcoded_subs: whitelisted_subtitle_tags whitelisted_hardcoded_subs: whitelisted_subtitle_tags
.clone() .unwrap_or(previous_indexer_settings.whitelisted_hardcoded_subs.text)
.unwrap_or_else(|| {
previous_indexer_settings
.whitelisted_hardcoded_subs
.text
.clone()
})
.into(), .into(),
}; };
self self
+142 -132
View File
@@ -1,13 +1,13 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{ use crate::{
cli::{
radarr::{edit_command_handler::RadarrEditCommand, RadarrCommand},
Command,
},
Cli, Cli,
cli::{
Command,
radarr::{RadarrCommand, edit_command_handler::RadarrEditCommand},
},
}; };
use clap::{error::ErrorKind, CommandFactory, Parser}; use clap::{CommandFactory, Parser, error::ErrorKind};
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -42,7 +42,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "all-indexer-settings"]); Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "all-indexer-settings"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -60,7 +60,7 @@ mod tests {
"--disable-allow-hardcoded-subs", "--disable-allow-hardcoded-subs",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -75,7 +75,7 @@ mod tests {
"--disable-prefer-indexer-flags", "--disable-prefer-indexer-flags",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -99,7 +99,7 @@ mod tests {
flag, flag,
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -126,12 +126,13 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_all_indexer_settings_all_arguments_defined() { fn test_edit_all_indexer_settings_all_arguments_defined() {
@@ -168,19 +169,20 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_collection_requires_arguments() { fn test_edit_collection_requires_arguments() {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "collection"]); Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "collection"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -198,7 +200,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -218,7 +220,7 @@ mod tests {
"--disable-monitoring", "--disable-monitoring",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -235,7 +237,7 @@ mod tests {
"--disable-search-on-add", "--disable-search-on-add",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -252,7 +254,7 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -270,7 +272,7 @@ mod tests {
flag, flag,
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -298,12 +300,13 @@ mod tests {
"/test", "/test",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_collection_all_arguments_defined() { fn test_edit_collection_all_arguments_defined() {
@@ -334,18 +337,19 @@ mod tests {
"--search-on-add", "--search-on-add",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_indexer_requires_arguments() { fn test_edit_indexer_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "indexer"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "indexer"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -363,7 +367,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -383,7 +387,7 @@ mod tests {
"--disable-rss", "--disable-rss",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -400,7 +404,7 @@ mod tests {
"--disable-automatic-search", "--disable-automatic-search",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -417,7 +421,7 @@ mod tests {
"--disable-interactive-search", "--disable-interactive-search",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -435,7 +439,7 @@ mod tests {
"--clear-tags", "--clear-tags",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -453,7 +457,7 @@ mod tests {
flag, flag,
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -487,12 +491,13 @@ mod tests {
"Test", "Test",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_indexer_tag_argument_is_repeatable() { fn test_edit_indexer_tag_argument_is_repeatable() {
@@ -526,12 +531,13 @@ mod tests {
"2", "2",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_indexer_all_arguments_defined() { fn test_edit_indexer_all_arguments_defined() {
@@ -578,18 +584,19 @@ mod tests {
"25", "25",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_movie_requires_arguments() { fn test_edit_movie_requires_arguments() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "movie"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "edit", "movie"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -607,7 +614,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -627,7 +634,7 @@ mod tests {
"--disable-monitoring", "--disable-monitoring",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -645,7 +652,7 @@ mod tests {
"--clear-tags", "--clear-tags",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict); assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
} }
@@ -669,7 +676,7 @@ mod tests {
flag, flag,
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -686,7 +693,7 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -714,12 +721,13 @@ mod tests {
"/nfs/test", "/nfs/test",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_movie_tag_argument_is_repeatable() { fn test_edit_movie_tag_argument_is_repeatable() {
@@ -747,12 +755,13 @@ mod tests {
"2", "2",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
}
#[test] #[test]
fn test_edit_movie_all_arguments_defined() { fn test_edit_movie_all_arguments_defined() {
@@ -787,13 +796,14 @@ mod tests {
"2", "2",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::Edit(edit_command))) = result.unwrap().command else {
panic!("Unexpected command type");
};
assert_eq!(edit_command, expected_args); assert_eq!(edit_command, expected_args);
} }
} }
}
mod handler { mod handler {
use std::sync::Arc; use std::sync::Arc;
@@ -805,31 +815,31 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::{ cli::{
radarr::edit_command_handler::{RadarrEditCommand, RadarrEditCommandHandler},
CliCommandHandler, CliCommandHandler,
radarr::edit_command_handler::{RadarrEditCommand, RadarrEditCommandHandler},
}, },
models::{ models::{
Serdeable,
radarr_models::{ radarr_models::{
EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability, EditCollectionParams, EditMovieParams, IndexerSettings, MinimumAvailability,
RadarrSerdeable, RadarrSerdeable,
}, },
servarr_models::EditIndexerParams, servarr_models::EditIndexerParams,
Serdeable,
}, },
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[tokio::test] #[tokio::test]
async fn test_handle_edit_all_indexer_settings_command() { async fn test_handle_edit_all_indexer_settings_command() {
let expected_edit_all_indexer_settings = IndexerSettings { let expected_edit_all_indexer_settings = IndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
availability_delay: 1, availability_delay: 19,
id: 1, id: 1,
maximum_size: 1, maximum_size: 18400,
minimum_age: 1, minimum_age: 27,
prefer_indexer_flags: true, prefer_indexer_flags: true,
retention: 1, retention: 44,
rss_sync_interval: 1, rss_sync_interval: 60,
whitelisted_hardcoded_subs: "test".into(), whitelisted_hardcoded_subs: "test".into(),
}; };
let mut mock_network = MockNetworkTrait::new(); let mut mock_network = MockNetworkTrait::new();
@@ -843,13 +853,13 @@ mod tests {
Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings(
IndexerSettings { IndexerSettings {
allow_hardcoded_subs: false, allow_hardcoded_subs: false,
availability_delay: 2, availability_delay: 31,
id: 1, id: 8,
maximum_size: 2, maximum_size: 22750,
minimum_age: 2, minimum_age: 52,
prefer_indexer_flags: false, prefer_indexer_flags: false,
retention: 2, retention: 66,
rss_sync_interval: 2, rss_sync_interval: 90,
whitelisted_hardcoded_subs: "testing".into(), whitelisted_hardcoded_subs: "testing".into(),
}, },
))) )))
@@ -865,17 +875,17 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
disable_allow_hardcoded_subs: false, disable_allow_hardcoded_subs: false,
availability_delay: Some(1), availability_delay: Some(19),
maximum_size: Some(1), maximum_size: Some(18400),
minimum_age: Some(1), minimum_age: Some(27),
prefer_indexer_flags: true, prefer_indexer_flags: true,
disable_prefer_indexer_flags: false, disable_prefer_indexer_flags: false,
retention: Some(1), retention: Some(44),
rss_sync_interval: Some(1), rss_sync_interval: Some(60),
whitelisted_subtitle_tags: Some("test".to_owned()), whitelisted_subtitle_tags: Some("test".to_owned()),
}; };
@@ -887,20 +897,20 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
async fn test_handle_edit_all_indexer_settings_command_disable_flags_function_properly() { async fn test_handle_edit_all_indexer_settings_command_disable_flags_function_properly() {
let expected_edit_all_indexer_settings = IndexerSettings { let expected_edit_all_indexer_settings = IndexerSettings {
allow_hardcoded_subs: false, allow_hardcoded_subs: false,
availability_delay: 1, availability_delay: 19,
id: 1, id: 1,
maximum_size: 1, maximum_size: 18400,
minimum_age: 1, minimum_age: 27,
prefer_indexer_flags: false, prefer_indexer_flags: false,
retention: 1, retention: 44,
rss_sync_interval: 1, rss_sync_interval: 60,
whitelisted_hardcoded_subs: "test".into(), whitelisted_hardcoded_subs: "test".into(),
}; };
let mut mock_network = MockNetworkTrait::new(); let mut mock_network = MockNetworkTrait::new();
@@ -914,13 +924,13 @@ mod tests {
Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings(
IndexerSettings { IndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
availability_delay: 2, availability_delay: 31,
id: 1, id: 8,
maximum_size: 2, maximum_size: 22750,
minimum_age: 2, minimum_age: 52,
prefer_indexer_flags: true, prefer_indexer_flags: true,
retention: 2, retention: 66,
rss_sync_interval: 2, rss_sync_interval: 90,
whitelisted_hardcoded_subs: "testing".into(), whitelisted_hardcoded_subs: "testing".into(),
}, },
))) )))
@@ -936,17 +946,17 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: false, allow_hardcoded_subs: false,
disable_allow_hardcoded_subs: true, disable_allow_hardcoded_subs: true,
availability_delay: Some(1), availability_delay: Some(19),
maximum_size: Some(1), maximum_size: Some(18400),
minimum_age: Some(1), minimum_age: Some(27),
prefer_indexer_flags: false, prefer_indexer_flags: false,
disable_prefer_indexer_flags: true, disable_prefer_indexer_flags: true,
retention: Some(1), retention: Some(44),
rss_sync_interval: Some(1), rss_sync_interval: Some(60),
whitelisted_subtitle_tags: Some("test".to_owned()), whitelisted_subtitle_tags: Some("test".to_owned()),
}; };
@@ -958,21 +968,21 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
async fn test_handle_edit_all_indexer_settings_command_unprovided_values_default_to_previous_values( async fn test_handle_edit_all_indexer_settings_command_unprovided_values_default_to_previous_values()
) { {
let expected_edit_all_indexer_settings = IndexerSettings { let expected_edit_all_indexer_settings = IndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
availability_delay: 2, availability_delay: 31,
id: 1, id: 1,
maximum_size: 2, maximum_size: 22750,
minimum_age: 2, minimum_age: 52,
prefer_indexer_flags: true, prefer_indexer_flags: false,
retention: 2, retention: 66,
rss_sync_interval: 2, rss_sync_interval: 90,
whitelisted_hardcoded_subs: "testing".into(), whitelisted_hardcoded_subs: "testing".into(),
}; };
let mut mock_network = MockNetworkTrait::new(); let mut mock_network = MockNetworkTrait::new();
@@ -986,13 +996,13 @@ mod tests {
Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings( Ok(Serdeable::Radarr(RadarrSerdeable::IndexerSettings(
IndexerSettings { IndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
availability_delay: 2, availability_delay: 31,
id: 1, id: 8,
maximum_size: 2, maximum_size: 22750,
minimum_age: 2, minimum_age: 52,
prefer_indexer_flags: true, prefer_indexer_flags: false,
retention: 2, retention: 66,
rss_sync_interval: 2, rss_sync_interval: 90,
whitelisted_hardcoded_subs: "testing".into(), whitelisted_hardcoded_subs: "testing".into(),
}, },
))) )))
@@ -1008,7 +1018,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings { let edit_all_indexer_settings_command = RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: false, allow_hardcoded_subs: false,
disable_allow_hardcoded_subs: false, disable_allow_hardcoded_subs: false,
@@ -1030,7 +1040,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1055,7 +1065,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection { let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1, collection_id: 1,
enable_monitoring: true, enable_monitoring: true,
@@ -1072,7 +1082,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1097,7 +1107,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection { let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1, collection_id: 1,
enable_monitoring: false, enable_monitoring: false,
@@ -1114,7 +1124,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1139,7 +1149,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_collection_command = RadarrEditCommand::Collection { let edit_collection_command = RadarrEditCommand::Collection {
collection_id: 1, collection_id: 1,
enable_monitoring: false, enable_monitoring: false,
@@ -1156,7 +1166,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1187,7 +1197,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer { let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1, indexer_id: 1,
name: Some("Test".to_owned()), name: Some("Test".to_owned()),
@@ -1210,7 +1220,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1241,7 +1251,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer { let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1, indexer_id: 1,
name: Some("Test".to_owned()), name: Some("Test".to_owned()),
@@ -1264,7 +1274,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1295,7 +1305,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_indexer_command = RadarrEditCommand::Indexer { let edit_indexer_command = RadarrEditCommand::Indexer {
indexer_id: 1, indexer_id: 1,
name: Some("Test".to_owned()), name: Some("Test".to_owned()),
@@ -1318,7 +1328,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1345,7 +1355,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie { let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1, movie_id: 1,
enable_monitoring: true, enable_monitoring: true,
@@ -1361,7 +1371,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1388,7 +1398,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie { let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1, movie_id: 1,
enable_monitoring: false, enable_monitoring: false,
@@ -1404,7 +1414,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -1431,7 +1441,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_movie_command = RadarrEditCommand::Movie { let edit_movie_command = RadarrEditCommand::Movie {
movie_id: 1, movie_id: 1,
enable_monitoring: false, enable_monitoring: false,
@@ -1447,7 +1457,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+2 -2
View File
@@ -1,13 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{command, Subcommand}; use clap::Subcommand;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{ use crate::{
app::App, app::App,
cli::{CliCommandHandler, Command}, cli::{CliCommandHandler, Command},
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
use super::RadarrCommand; use super::RadarrCommand;
+27 -27
View File
@@ -1,12 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory; use clap::CommandFactory;
use clap::error::ErrorKind;
use crate::cli::radarr::get_command_handler::RadarrGetCommand;
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli; use crate::Cli;
use crate::cli::Command;
use crate::cli::radarr::RadarrCommand;
use crate::cli::radarr::get_command_handler::RadarrGetCommand;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -27,7 +27,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "all-indexer-settings"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -35,7 +35,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "host-config"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "host-config"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -43,7 +43,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-details"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-details"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -61,7 +61,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -69,7 +69,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-history"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "movie-history"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -87,7 +87,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -95,7 +95,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "security-config"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "security-config"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -103,7 +103,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]); Cli::command().try_get_matches_from(["managarr", "radarr", "get", "system-status"]);
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
@@ -117,11 +117,11 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::{ cli::{
radarr::get_command_handler::{RadarrGetCommand, RadarrGetCommandHandler},
CliCommandHandler, CliCommandHandler,
radarr::get_command_handler::{RadarrGetCommand, RadarrGetCommandHandler},
}, },
models::{radarr_models::RadarrSerdeable, Serdeable}, models::{Serdeable, radarr_models::RadarrSerdeable},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[tokio::test] #[tokio::test]
@@ -138,7 +138,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_all_indexer_settings_command = RadarrGetCommand::AllIndexerSettings; let get_all_indexer_settings_command = RadarrGetCommand::AllIndexerSettings;
let result = RadarrGetCommandHandler::with( let result = RadarrGetCommandHandler::with(
@@ -149,7 +149,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -164,7 +164,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_host_config_command = RadarrGetCommand::HostConfig; let get_host_config_command = RadarrGetCommand::HostConfig;
let result = let result =
@@ -172,7 +172,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -190,7 +190,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_movie_details_command = RadarrGetCommand::MovieDetails { movie_id: 1 }; let get_movie_details_command = RadarrGetCommand::MovieDetails { movie_id: 1 };
let result = let result =
@@ -198,7 +198,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -216,7 +216,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_movie_history_command = RadarrGetCommand::MovieHistory { movie_id: 1 }; let get_movie_history_command = RadarrGetCommand::MovieHistory { movie_id: 1 };
let result = let result =
@@ -224,7 +224,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -239,7 +239,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_security_config_command = RadarrGetCommand::SecurityConfig; let get_security_config_command = RadarrGetCommand::SecurityConfig;
let result = let result =
@@ -247,7 +247,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -262,7 +262,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = RadarrGetCommand::SystemStatus; let get_system_status_command = RadarrGetCommand::SystemStatus;
let result = let result =
@@ -270,7 +270,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+22 -7
View File
@@ -1,13 +1,13 @@
use std::sync::Arc; use std::sync::Arc;
use anyhow::Result; use anyhow::Result;
use clap::{command, Subcommand}; use clap::Subcommand;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use crate::{ use crate::{
app::App, app::App,
cli::{CliCommandHandler, Command}, cli::{CliCommandHandler, Command},
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
use super::RadarrCommand; use super::RadarrCommand;
@@ -23,9 +23,17 @@ pub enum RadarrListCommand {
#[command(about = "List all Radarr collections")] #[command(about = "List all Radarr collections")]
Collections, Collections,
#[command(about = "List all active downloads in Radarr")] #[command(about = "List all active downloads in Radarr")]
Downloads, Downloads {
#[arg(long, help = "How many downloads to fetch", default_value_t = 500)]
count: u64,
},
#[command(about = "List disk space details for all provisioned root folders in Radarr")] #[command(about = "List disk space details for all provisioned root folders in Radarr")]
DiskSpace, DiskSpace,
#[command(about = "Fetch all Radarr history events")]
History {
#[arg(long, help = "How many history events to fetch", default_value_t = 500)]
events: u64,
},
#[command(about = "List all Radarr indexers")] #[command(about = "List all Radarr indexers")]
Indexers, Indexers,
#[command(about = "Fetch Radarr logs")] #[command(about = "Fetch Radarr logs")]
@@ -104,10 +112,10 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
RadarrListCommand::Downloads => { RadarrListCommand::Downloads { count } => {
let resp = self let resp = self
.network .network
.handle_network_event(RadarrEvent::GetDownloads.into()) .handle_network_event(RadarrEvent::GetDownloads(count).into())
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
@@ -118,6 +126,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
RadarrListCommand::History { events: items } => {
let resp = self
.network
.handle_network_event(RadarrEvent::GetHistory(items).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrListCommand::Indexers => { RadarrListCommand::Indexers => {
let resp = self let resp = self
.network .network
@@ -135,9 +150,9 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrListCommand> for RadarrListCommandH
.await?; .await?;
if output_in_log_format { if output_in_log_format {
let log_lines = self.app.lock().await.data.radarr_data.logs.items.clone(); let log_lines = &self.app.lock().await.data.radarr_data.logs.items;
serde_json::to_string_pretty(&log_lines)? serde_json::to_string_pretty(log_lines)?
} else { } else {
serde_json::to_string_pretty(&logs)? serde_json::to_string_pretty(&logs)?
} }
+122 -22
View File
@@ -1,12 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory; use clap::CommandFactory;
use clap::error::ErrorKind;
use crate::cli::radarr::list_command_handler::RadarrListCommand;
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli; use crate::Cli;
use crate::cli::Command;
use crate::cli::radarr::RadarrCommand;
use crate::cli::radarr::list_command_handler::RadarrListCommand;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -29,7 +29,6 @@ mod tests {
#[values( #[values(
"blocklist", "blocklist",
"collections", "collections",
"downloads",
"disk-space", "disk-space",
"indexers", "indexers",
"movies", "movies",
@@ -44,7 +43,7 @@ mod tests {
) { ) {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "list", subcommand]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "list", subcommand]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
@@ -52,19 +51,28 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "list", "movie-credits"]); Cli::command().try_get_matches_from(["managarr", "radarr", "list", "movie-credits"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
); );
} }
#[test]
fn test_list_downloads_count_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "list", "downloads", "--count"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test] #[test]
fn test_list_logs_events_flag_requires_arguments() { fn test_list_logs_events_flag_requires_arguments() {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "list", "logs", "--events"]); Cli::command().try_get_matches_from(["managarr", "radarr", "list", "logs", "--events"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -80,11 +88,50 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::List(credits_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::List(credits_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(credits_command, expected_args); assert_eq!(credits_command, expected_args);
} }
#[test]
fn test_list_downloads_default_values() {
let expected_args = RadarrListCommand::Downloads { count: 500 };
let result = Cli::try_parse_from(["managarr", "radarr", "list", "downloads"]);
assert_ok!(&result);
let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(refresh_command, expected_args);
}
#[test]
fn test_list_history_events_flag_requires_arguments() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "list", "history", "--events"]);
assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
}
#[test]
fn test_list_history_default_values() {
let expected_args = RadarrListCommand::History { events: 500 };
let result = Cli::try_parse_from(["managarr", "radarr", "list", "history"]);
assert_ok!(&result);
let Some(Command::Radarr(RadarrCommand::List(history_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(history_command, expected_args);
} }
#[test] #[test]
@@ -95,13 +142,15 @@ mod tests {
}; };
let result = Cli::try_parse_from(["managarr", "radarr", "list", "logs"]); let result = Cli::try_parse_from(["managarr", "radarr", "list", "logs"]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command { let Some(Command::Radarr(RadarrCommand::List(refresh_command))) = result.unwrap().command
else {
panic!("Unexpected command type");
};
assert_eq!(refresh_command, expected_args); assert_eq!(refresh_command, expected_args);
} }
} }
}
mod handler { mod handler {
use std::sync::Arc; use std::sync::Arc;
@@ -115,14 +164,13 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::radarr::list_command_handler::{RadarrListCommand, RadarrListCommandHandler}, cli::radarr::list_command_handler::{RadarrListCommand, RadarrListCommandHandler},
models::{radarr_models::RadarrSerdeable, Serdeable}, models::{Serdeable, radarr_models::RadarrSerdeable},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[rstest] #[rstest]
#[case(RadarrListCommand::Blocklist, RadarrEvent::GetBlocklist)] #[case(RadarrListCommand::Blocklist, RadarrEvent::GetBlocklist)]
#[case(RadarrListCommand::Collections, RadarrEvent::GetCollections)] #[case(RadarrListCommand::Collections, RadarrEvent::GetCollections)]
#[case(RadarrListCommand::Downloads, RadarrEvent::GetDownloads)]
#[case(RadarrListCommand::DiskSpace, RadarrEvent::GetDiskSpace)] #[case(RadarrListCommand::DiskSpace, RadarrEvent::GetDiskSpace)]
#[case(RadarrListCommand::Indexers, RadarrEvent::GetIndexers)] #[case(RadarrListCommand::Indexers, RadarrEvent::GetIndexers)]
#[case(RadarrListCommand::Movies, RadarrEvent::GetMovies)] #[case(RadarrListCommand::Movies, RadarrEvent::GetMovies)]
@@ -147,13 +195,13 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = RadarrListCommandHandler::with(&app_arc, list_command, &mut mock_network) let result = RadarrListCommandHandler::with(&app_arc, list_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -171,7 +219,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_movie_credits_command = RadarrListCommand::MovieCredits { movie_id: 1 }; let list_movie_credits_command = RadarrListCommand::MovieCredits { movie_id: 1 };
let result = let result =
@@ -179,7 +227,59 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
}
#[tokio::test]
async fn test_handle_list_downloads_command() {
let expected_count = 1000;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::GetDownloads(expected_count).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_downloads_command = RadarrListCommand::Downloads { count: 1000 };
let result =
RadarrListCommandHandler::with(&app_arc, list_downloads_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[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::<NetworkEvent>(
RadarrEvent::GetHistory(expected_events).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_history_command = RadarrListCommand::History { events: 1000 };
let result =
RadarrListCommandHandler::with(&app_arc, list_history_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -197,7 +297,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_logs_command = RadarrListCommand::Logs { let list_logs_command = RadarrListCommand::Logs {
events: 1000, events: 1000,
output_in_log_format: false, output_in_log_format: false,
@@ -207,7 +307,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+37 -1
View File
@@ -13,8 +13,8 @@ use crate::app::App;
use crate::cli::CliCommandHandler; use crate::cli::CliCommandHandler;
use crate::models::radarr_models::{RadarrReleaseDownloadBody, RadarrTaskName}; use crate::models::radarr_models::{RadarrReleaseDownloadBody, RadarrTaskName};
use crate::network::radarr_network::RadarrEvent;
use crate::network::NetworkTrait; use crate::network::NetworkTrait;
use crate::network::radarr_network::RadarrEvent;
use anyhow::Result; use anyhow::Result;
use super::Command; use super::Command;
@@ -64,6 +64,15 @@ pub enum RadarrCommand {
Refresh(RadarrRefreshCommand), Refresh(RadarrRefreshCommand),
#[command(about = "Clear the blocklist")] #[command(about = "Clear the blocklist")]
ClearBlocklist, ClearBlocklist,
#[command(about = "Mark the Radarr history item with the given ID as 'failed'")]
MarkHistoryItemAsFailed {
#[arg(
long,
help = "The Radarr ID of the history item you wish to mark as 'failed'",
required = true
)]
history_item_id: i64,
},
#[command(about = "Manually download the given release for the specified movie ID")] #[command(about = "Manually download the given release for the specified movie ID")]
DownloadRelease { DownloadRelease {
#[arg(long, help = "The GUID of the release to download", required = true)] #[arg(long, help = "The GUID of the release to download", required = true)]
@@ -118,6 +127,17 @@ pub enum RadarrCommand {
}, },
#[command(about = "Test all Radarr indexers")] #[command(about = "Test all Radarr indexers")]
TestAllIndexers, TestAllIndexers,
#[command(
about = "Toggle monitoring for the specified movie corresponding to the given movie ID"
)]
ToggleMovieMonitoring {
#[arg(
long,
help = "The Radarr ID of the movie to toggle monitoring on",
required = true
)]
movie_id: i64,
},
#[command(about = "Trigger an automatic search for the movie with the specified ID")] #[command(about = "Trigger an automatic search for the movie with the specified ID")]
TriggerAutomaticSearch { TriggerAutomaticSearch {
#[arg( #[arg(
@@ -197,6 +217,15 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, '
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
RadarrCommand::MarkHistoryItemAsFailed { history_item_id } => {
let _ = self
.network
.handle_network_event(RadarrEvent::MarkHistoryItemAsFailed(history_item_id).into())
.await?;
serde_json::to_string_pretty(
&serde_json::json!({"message": "Radarr history item marked as 'failed'"}),
)?
}
RadarrCommand::DownloadRelease { RadarrCommand::DownloadRelease {
guid, guid,
indexer_id, indexer_id,
@@ -250,6 +279,13 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, RadarrCommand> for RadarrCliHandler<'a, '
.await?; .await?;
serde_json::to_string_pretty(&resp)? serde_json::to_string_pretty(&resp)?
} }
RadarrCommand::ToggleMovieMonitoring { movie_id } => {
let resp = self
.network
.handle_network_event(RadarrEvent::ToggleMovieMonitoring(movie_id).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
RadarrCommand::TriggerAutomaticSearch { movie_id } => { RadarrCommand::TriggerAutomaticSearch { movie_id } => {
let resp = self let resp = self
.network .network
+161 -55
View File
@@ -1,11 +1,11 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory; use clap::CommandFactory;
use clap::error::ErrorKind;
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli; use crate::Cli;
use crate::cli::Command;
use crate::cli::radarr::RadarrCommand;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -28,7 +28,32 @@ mod tests {
) { ) {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", subcommand]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", subcommand]);
assert!(result.is_ok()); assert_ok!(&result);
}
#[test]
fn test_mark_history_item_as_failed_requires_history_item_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "mark-history-item-as-failed"]);
assert_err!(&result);
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",
"radarr",
"mark-history-item-as-failed",
"--history-item-id",
"1",
]);
assert_ok!(&result);
} }
#[test] #[test]
@@ -43,7 +68,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -62,7 +87,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -81,7 +106,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -102,14 +127,14 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_manual_search_requires_movie_id() { fn test_manual_search_requires_movie_id() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "manual-search"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "manual-search"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -126,14 +151,14 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_search_new_movie_requires_query() { fn test_search_new_movie_requires_query() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "search-new-movie"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "search-new-movie"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -150,14 +175,14 @@ mod tests {
"halo", "halo",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_start_task_requires_task_name() { fn test_start_task_requires_task_name() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "start-task"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "start-task"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -174,7 +199,7 @@ mod tests {
"test", "test",
]); ]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue); assert_eq!(result.unwrap_err().kind(), ErrorKind::InvalidValue);
} }
@@ -188,14 +213,14 @@ mod tests {
"application-check-update", "application-check-update",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_test_indexer_requires_indexer_id() { fn test_test_indexer_requires_indexer_id() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "test-indexer"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "test-indexer"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -212,7 +237,32 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
}
#[test]
fn test_toggle_movie_monitoring_requires_movie_id() {
let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "toggle-movie-monitoring"]);
assert_err!(&result);
assert_eq!(
result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument
);
}
#[test]
fn test_toggle_movie_monitoring_requirements_satisfied() {
let result = Cli::command().try_get_matches_from([
"managarr",
"radarr",
"toggle-movie-monitoring",
"--movie-id",
"1",
]);
assert_ok!(&result);
} }
#[test] #[test]
@@ -220,7 +270,7 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "trigger-automatic-search"]); Cli::command().try_get_matches_from(["managarr", "radarr", "trigger-automatic-search"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -237,7 +287,7 @@ mod tests {
"1", "1",
]); ]);
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
@@ -251,22 +301,22 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::{ cli::{
radarr::{
add_command_handler::RadarrAddCommand, delete_command_handler::RadarrDeleteCommand,
edit_command_handler::RadarrEditCommand, get_command_handler::RadarrGetCommand,
list_command_handler::RadarrListCommand, refresh_command_handler::RadarrRefreshCommand,
RadarrCliHandler, RadarrCommand,
},
CliCommandHandler, CliCommandHandler,
radarr::{
RadarrCliHandler, RadarrCommand, add_command_handler::RadarrAddCommand,
delete_command_handler::RadarrDeleteCommand, edit_command_handler::RadarrEditCommand,
get_command_handler::RadarrGetCommand, list_command_handler::RadarrListCommand,
refresh_command_handler::RadarrRefreshCommand,
},
}, },
models::{ models::{
Serdeable,
radarr_models::{ radarr_models::{
BlocklistItem, BlocklistResponse, IndexerSettings, RadarrReleaseDownloadBody, BlocklistItem, BlocklistResponse, IndexerSettings, RadarrReleaseDownloadBody,
RadarrSerdeable, RadarrTaskName, RadarrSerdeable, RadarrTaskName,
}, },
Serdeable,
}, },
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[tokio::test] #[tokio::test]
@@ -292,14 +342,44 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let clear_blocklist_command = RadarrCommand::ClearBlocklist; let clear_blocklist_command = RadarrCommand::ClearBlocklist;
let result = RadarrCliHandler::with(&app_arc, clear_blocklist_command, &mut mock_network) let result = RadarrCliHandler::with(&app_arc, clear_blocklist_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
}
#[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::<NetworkEvent>(
RadarrEvent::MarkHistoryItemAsFailed(expected_history_item_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let mark_history_item_as_failed_command =
RadarrCommand::MarkHistoryItemAsFailed { history_item_id: 1 };
let result = RadarrCliHandler::with(
&app_arc,
mark_history_item_as_failed_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -321,7 +401,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let download_release_command = RadarrCommand::DownloadRelease { let download_release_command = RadarrCommand::DownloadRelease {
guid: "guid".to_owned(), guid: "guid".to_owned(),
indexer_id: 1, indexer_id: 1,
@@ -332,7 +412,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -350,14 +430,14 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let manual_search_command = RadarrCommand::ManualSearch { movie_id: 1 }; let manual_search_command = RadarrCommand::ManualSearch { movie_id: 1 };
let result = RadarrCliHandler::with(&app_arc, manual_search_command, &mut mock_network) let result = RadarrCliHandler::with(&app_arc, manual_search_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -375,7 +455,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let search_new_movie_command = RadarrCommand::SearchNewMovie { let search_new_movie_command = RadarrCommand::SearchNewMovie {
query: "halo".to_owned(), query: "halo".to_owned(),
}; };
@@ -384,7 +464,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -402,7 +482,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let start_task_command = RadarrCommand::StartTask { let start_task_command = RadarrCommand::StartTask {
task_name: RadarrTaskName::ApplicationCheckUpdate, task_name: RadarrTaskName::ApplicationCheckUpdate,
}; };
@@ -411,7 +491,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -429,14 +509,14 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_indexer_command = RadarrCommand::TestIndexer { indexer_id: 1 }; let test_indexer_command = RadarrCommand::TestIndexer { indexer_id: 1 };
let result = RadarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network) let result = RadarrCliHandler::with(&app_arc, test_indexer_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -451,14 +531,40 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let test_all_indexers_command = RadarrCommand::TestAllIndexers; let test_all_indexers_command = RadarrCommand::TestAllIndexers;
let result = RadarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network) let result = RadarrCliHandler::with(&app_arc, test_all_indexers_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
}
#[tokio::test]
async fn test_toggle_movie_monitoring_command() {
let expected_movie_id = 1;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
RadarrEvent::ToggleMovieMonitoring(expected_movie_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Radarr(RadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let toggle_movie_monitoring_command = RadarrCommand::ToggleMovieMonitoring { movie_id: 1 };
let result =
RadarrCliHandler::with(&app_arc, toggle_movie_monitoring_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -476,7 +582,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command = RadarrCommand::TriggerAutomaticSearch { movie_id: 1 }; let trigger_automatic_search_command = RadarrCommand::TriggerAutomaticSearch { movie_id: 1 };
let result = RadarrCliHandler::with( let result = RadarrCliHandler::with(
@@ -487,7 +593,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -505,7 +611,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = RadarrCommand::Add(RadarrAddCommand::Tag { let add_tag_command = RadarrCommand::Add(RadarrAddCommand::Tag {
name: expected_tag_name, name: expected_tag_name,
}); });
@@ -514,7 +620,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -532,7 +638,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_blocklist_item_command = let delete_blocklist_item_command =
RadarrCommand::Delete(RadarrDeleteCommand::BlocklistItem { RadarrCommand::Delete(RadarrDeleteCommand::BlocklistItem {
blocklist_item_id: 1, blocklist_item_id: 1,
@@ -543,7 +649,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -592,7 +698,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command = let edit_all_indexer_settings_command =
RadarrCommand::Edit(RadarrEditCommand::AllIndexerSettings { RadarrCommand::Edit(RadarrEditCommand::AllIndexerSettings {
allow_hardcoded_subs: true, allow_hardcoded_subs: true,
@@ -615,7 +721,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -632,7 +738,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_all_indexer_settings_command = let get_all_indexer_settings_command =
RadarrCommand::Get(RadarrGetCommand::AllIndexerSettings); RadarrCommand::Get(RadarrGetCommand::AllIndexerSettings);
@@ -644,7 +750,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -662,7 +768,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_movie_credits_command = let list_movie_credits_command =
RadarrCommand::List(RadarrListCommand::MovieCredits { movie_id: 1 }); RadarrCommand::List(RadarrListCommand::MovieCredits { movie_id: 1 });
@@ -670,7 +776,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -688,7 +794,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_movie_command = let refresh_movie_command =
RadarrCommand::Refresh(RadarrRefreshCommand::Movie { movie_id: 1 }); RadarrCommand::Refresh(RadarrRefreshCommand::Movie { movie_id: 1 });
@@ -696,7 +802,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+1 -1
View File
@@ -7,7 +7,7 @@ use tokio::sync::Mutex;
use crate::{ use crate::{
app::App, app::App,
cli::{CliCommandHandler, Command}, cli::{CliCommandHandler, Command},
network::{radarr_network::RadarrEvent, NetworkTrait}, network::{NetworkTrait, radarr_network::RadarrEvent},
}; };
use super::RadarrCommand; use super::RadarrCommand;
+17 -17
View File
@@ -1,12 +1,12 @@
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use clap::error::ErrorKind;
use clap::CommandFactory; use clap::CommandFactory;
use clap::error::ErrorKind;
use crate::cli::radarr::refresh_command_handler::RadarrRefreshCommand;
use crate::cli::radarr::RadarrCommand;
use crate::cli::Command;
use crate::Cli; use crate::Cli;
use crate::cli::Command;
use crate::cli::radarr::RadarrCommand;
use crate::cli::radarr::refresh_command_handler::RadarrRefreshCommand;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
#[test] #[test]
@@ -31,14 +31,14 @@ mod tests {
let result = let result =
Cli::command().try_get_matches_from(["managarr", "radarr", "refresh", subcommand]); Cli::command().try_get_matches_from(["managarr", "radarr", "refresh", subcommand]);
assert!(result.is_ok()); assert_ok!(&result);
} }
#[test] #[test]
fn test_refresh_movie_requires_movie_id() { fn test_refresh_movie_requires_movie_id() {
let result = Cli::command().try_get_matches_from(["managarr", "radarr", "refresh", "movie"]); let result = Cli::command().try_get_matches_from(["managarr", "radarr", "refresh", "movie"]);
assert!(result.is_err()); assert_err!(&result);
assert_eq!( assert_eq!(
result.unwrap_err().kind(), result.unwrap_err().kind(),
ErrorKind::MissingRequiredArgument ErrorKind::MissingRequiredArgument
@@ -51,15 +51,15 @@ mod tests {
let result = let result =
Cli::try_parse_from(["managarr", "radarr", "refresh", "movie", "--movie-id", "1"]); Cli::try_parse_from(["managarr", "radarr", "refresh", "movie", "--movie-id", "1"]);
assert!(result.is_ok()); assert_ok!(&result);
if let Some(Command::Radarr(RadarrCommand::Refresh(refresh_command))) = let Some(Command::Radarr(RadarrCommand::Refresh(refresh_command))) = result.unwrap().command
result.unwrap().command else {
{ panic!("Unexpected command type");
};
assert_eq!(refresh_command, expected_args); assert_eq!(refresh_command, expected_args);
} }
} }
}
mod handler { mod handler {
use rstest::rstest; use rstest::rstest;
@@ -73,8 +73,8 @@ mod tests {
use crate::{ use crate::{
app::App, app::App,
cli::radarr::refresh_command_handler::{RadarrRefreshCommand, RadarrRefreshCommandHandler}, cli::radarr::refresh_command_handler::{RadarrRefreshCommand, RadarrRefreshCommandHandler},
models::{radarr_models::RadarrSerdeable, Serdeable}, models::{Serdeable, radarr_models::RadarrSerdeable},
network::{radarr_network::RadarrEvent, MockNetworkTrait, NetworkEvent}, network::{MockNetworkTrait, NetworkEvent, radarr_network::RadarrEvent},
}; };
#[rstest] #[rstest]
@@ -96,13 +96,13 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let result = RadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network) let result = RadarrRefreshCommandHandler::with(&app_arc, refresh_command, &mut mock_network)
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
#[tokio::test] #[tokio::test]
@@ -120,7 +120,7 @@ mod tests {
json!({"testResponse": "response"}), json!({"testResponse": "response"}),
))) )))
}); });
let app_arc = Arc::new(Mutex::new(App::default())); let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_movie_command = RadarrRefreshCommand::Movie { movie_id: 1 }; let refresh_movie_command = RadarrRefreshCommand::Movie { movie_id: 1 };
let result = let result =
@@ -128,7 +128,7 @@ mod tests {
.handle() .handle()
.await; .await;
assert!(result.is_ok()); assert_ok!(&result);
} }
} }
} }
+224
View File
@@ -0,0 +1,224 @@
use std::sync::Arc;
use anyhow::Result;
use clap::{ArgAction, Subcommand};
use tokio::sync::Mutex;
use super::ReadarrCommand;
use crate::{
app::App,
cli::{CliCommandHandler, Command},
models::readarr_models::{
AddAuthorBody, AddAuthorOptions, AddReadarrRootFolderBody, MonitorType, NewItemMonitorType,
},
network::{NetworkTrait, readarr_network::ReadarrEvent},
};
#[cfg(test)]
#[path = "add_command_handler_tests.rs"]
mod add_command_handler_tests;
#[derive(Debug, Clone, PartialEq, Eq, Subcommand)]
pub enum ReadarrAddCommand {
#[command(about = "Add a new author to your Readarr library")]
Author {
#[arg(
long,
help = "The Goodreads foreign author ID of the author you wish to add to your library",
required = true
)]
foreign_author_id: String,
#[arg(long, help = "The name of the author", required = true)]
author_name: String,
#[arg(
long,
help = "The root folder path where all author data and metadata should live",
required = true
)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the quality profile to use for this author",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the metadata profile to use for this author",
required = true
)]
metadata_profile_id: i64,
#[arg(long, help = "Disable monitoring for this author")]
disable_monitoring: bool,
#[arg(
long,
help = "Tag IDs to tag the author with",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
#[arg(
long,
help = "What Readarr should monitor for this author",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "How Readarr should monitor new items for this author",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Tell Readarr to not start a search for missing books once the author is added to your library"
)]
no_search_for_missing_books: bool,
},
#[command(about = "Add a new root folder")]
RootFolder {
#[arg(long, help = "The name of the root folder", required = true)]
name: String,
#[arg(long, help = "The path of the new root folder", required = true)]
root_folder_path: String,
#[arg(
long,
help = "The ID of the default quality profile for authors in this root folder",
required = true
)]
quality_profile_id: i64,
#[arg(
long,
help = "The ID of the default metadata profile for authors in this root folder",
required = true
)]
metadata_profile_id: i64,
#[arg(
long,
help = "The default monitor option for authors in this root folder",
value_enum,
default_value_t = MonitorType::default()
)]
monitor: MonitorType,
#[arg(
long,
help = "The default monitor new items option for authors in this root folder",
value_enum,
default_value_t = NewItemMonitorType::default()
)]
monitor_new_items: NewItemMonitorType,
#[arg(
long,
help = "Default tag IDs for authors in this root folder",
value_parser,
action = ArgAction::Append
)]
tag: Vec<i64>,
},
#[command(about = "Add new tag")]
Tag {
#[arg(long, help = "The name of the tag to be added", required = true)]
name: String,
},
}
impl From<ReadarrAddCommand> for Command {
fn from(value: ReadarrAddCommand) -> Self {
Command::Readarr(ReadarrCommand::Add(value))
}
}
pub(super) struct ReadarrAddCommandHandler<'a, 'b> {
_app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrAddCommand,
network: &'a mut dyn NetworkTrait,
}
impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandHandler<'a, 'b> {
fn with(
app: &'a Arc<Mutex<App<'b>>>,
command: ReadarrAddCommand,
network: &'a mut dyn NetworkTrait,
) -> Self {
ReadarrAddCommandHandler {
_app: app,
command,
network,
}
}
async fn handle(self) -> Result<String> {
let result = match self.command {
ReadarrAddCommand::Author {
foreign_author_id,
author_name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
disable_monitoring,
tag: tags,
monitor,
monitor_new_items,
no_search_for_missing_books,
} => {
let body = AddAuthorBody {
foreign_author_id,
author_name,
monitored: !disable_monitoring,
root_folder_path,
quality_profile_id,
metadata_profile_id,
tags,
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor,
monitor_new_items,
search_for_missing_books: !no_search_for_missing_books,
},
};
let resp = self
.network
.handle_network_event(ReadarrEvent::AddAuthor(body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
ReadarrAddCommand::RootFolder {
name,
root_folder_path,
quality_profile_id,
metadata_profile_id,
monitor,
monitor_new_items,
tag: tags,
} => {
let add_root_folder_body = AddReadarrRootFolderBody {
name,
path: root_folder_path,
default_quality_profile_id: quality_profile_id,
default_metadata_profile_id: metadata_profile_id,
default_monitor_option: monitor,
default_new_item_monitor_option: monitor_new_items,
default_tags: tags,
tag_input_string: None,
};
let resp = self
.network
.handle_network_event(ReadarrEvent::AddRootFolder(add_root_folder_body).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
ReadarrAddCommand::Tag { name } => {
let resp = self
.network
.handle_network_event(ReadarrEvent::AddTag(name).into())
.await?;
serde_json::to_string_pretty(&resp)?
}
};
Ok(result)
}
}
Loaded 100 of 1511 files, more files were not shown because too many files have changed in this diff. Show more