Compare 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
197 changed files with 4004 additions and 1424 deletions

No files matched your search

@@ -1,11 +0,0 @@
### AI assistance (if any):
- List tools here and files touched by them
### Authorship & Understanding
- [ ] I wrote or heavily modified this code myself
- [ ] 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 below
+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
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -46,7 +46,7 @@ jobs:
toolchain: [stable, beta]
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -54,7 +54,7 @@ jobs:
components: clippy
- name: Run clippy action
uses: clechasseur/rs-clippy-check@v3
uses: clechasseur/rs-clippy-check@v5
- name: Cache Cargo dependencies
uses: Swatinem/rust-cache@v2
@@ -65,7 +65,7 @@ jobs:
name: nightly / doc
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install Rust nightly
uses: dtolnay/rust-toolchain@nightly
@@ -79,7 +79,7 @@ jobs:
name: 1.95.0 / check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install 1.95.0
uses: dtolnay/rust-toolchain@master
+28 -28
View File
@@ -32,19 +32,19 @@ jobs:
- name: Checkout repository
if: env.ACT != 'true'
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
ssh-key: ${{ secrets.RELEASE_BOT_SSH_KEY }}
fetch-depth: 0
- name: Checkout repository
if: env.ACT == 'true'
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v6
with:
python-version: '3.10'
@@ -219,13 +219,13 @@ jobs:
git push origin --follow-tags
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: artifacts
- name: Upload the changed Cargo files (Act)
if: env.ACT == 'true'
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: bumped-cargo-files
path: |
@@ -267,7 +267,7 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
@@ -279,18 +279,18 @@ jobs:
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
- uses: actions/cache@v3
- uses: actions/cache@v5
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
- uses: actions/cache@v5
if: startsWith(matrix.job.name, 'linux-')
with:
path: ~/.cargo/bin
@@ -375,7 +375,7 @@ jobs:
cp target/${{ matrix.job.target }}/release/${{ env.RELEASE_NAME }}.sha256 artifacts/
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
name: artifacts-${{ env.RELEASE_NAME }}
path: artifacts
@@ -393,12 +393,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v3
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Download all artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -421,7 +421,7 @@ jobs:
- name: Create a GitHub Release
if: env.ACT != 'true'
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -457,7 +457,7 @@ jobs:
prerelease: false
- name: Upload artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v6
with:
path: artifacts
overwrite: true
@@ -474,12 +474,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -528,12 +528,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -592,12 +592,12 @@ jobs:
exit 1
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 1
- name: Get release artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
path: artifacts
merge-multiple: true
@@ -618,20 +618,20 @@ jobs:
echo "Release version: ${{ env.version }}"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub
if: env.ACT != 'true'
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Push to Docker Hub
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile
@@ -651,13 +651,13 @@ jobs:
exit 1
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Get bumped Cargo files (Act)
if: env.ACT == 'true'
uses: actions/download-artifact@v4
uses: actions/download-artifact@v7
with:
name: bumped-cargo-files
path: ${{ github.workspace }}
@@ -668,13 +668,13 @@ jobs:
git fetch --all
git pull
- uses: actions/cache@v3
- uses: actions/cache@v5
name: Cache Cargo registry
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('Cargo.lock') }}
- uses: actions/cache@v3
- uses: actions/cache@v5
with:
path: ~/.cargo/bin
key: ${{ runner.os }}-cargo-bin-${{ hashFiles('.github/workflows/release.yml') }}
+5 -5
View File
@@ -33,7 +33,7 @@ jobs:
# toolchain
toolchain: [stable, beta]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Install ${{ matrix.toolchain }}
uses: dtolnay/rust-toolchain@master
@@ -74,7 +74,7 @@ jobs:
# runs-on: ubuntu-latest
# name: ubuntu / stable / minimal-versions
# steps:
# - uses: actions/checkout@v4
# - uses: actions/checkout@v5
# - name: Install Rust stable
# uses: dtolnay/rust-toolchain@stable
@@ -110,7 +110,7 @@ jobs:
# - run: vcpkg install openssl:x64-windows-static-md
# if: runner.os == 'Windows'
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -151,7 +151,7 @@ jobs:
name: ubuntu / stable / coverage
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
@@ -176,7 +176,7 @@ jobs:
- name: Upload to codecov.io
if: env.ACT != 'true'
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v7
with:
fail_ci_if_error: true
token: ${{ secrets.CODECOV_TOKEN }}
+2 -2
View File
@@ -93,8 +93,8 @@ act -W .github/workflows/release.yml --input_type bump=minor
## Authorship Policy
All code in this repository is written and reviewed by humans. AI-generated code (e.g., Copilot, ChatGPT,
Claude, etc.) is not permitted unless explicitly disclosed and approved.
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.
Generated
+473 -467
View File
File diff suppressed because it is too large. Load diff
+123 -50
View File
@@ -26,6 +26,11 @@ Managarr is a TUI and CLI to help you manage your HTPC (Home Theater PC). Built
- [ ] ![bazarr_logo](logos/bazarr.png) [Bazarr](https://www.bazarr.media/)
- [ ] ![tautulli_logo](logos/tautulli.png) [Tautulli](https://tautulli.com/)
> [!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.
Simply run the following command to start a demo:
@@ -134,8 +139,8 @@ Key:
| Symbol | Status |
|--------|-----------|
| ✅ | Supported |
| ❌ | Missing |
| ✅ | Supported |
| ❌ | Missing |
| 🕒 | Planned |
| 🚫 | Won't Add |
@@ -143,66 +148,82 @@ Key:
| TUI | CLI | Feature |
|-----|-----|----------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, 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 |
| ✅ | ✅ | View your library, downloads, collections, and blocklist |
| ✅ | ✅ | View details of a specific movie including description, history, downloaded file info, or the credits |
| ✅ | ✅ | View details of any collection and the movies in them |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library or collections |
| ✅ | ✅ | Add movies to your library |
| ✅ | ✅ | Delete movies, downloads, and indexers |
| ✅ | ✅ | Trigger automatic searches for movies |
| ✅ | ✅ | Trigger refresh and disk scan for movies, downloads, and collections |
| ✅ | ✅ | Manually search for movies |
| ✅ | ✅ | Edit your movies, collections, 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 |
### Sonarr
| TUI | CLI | Feature |
|-----|-----|--------------------------------------------------------------------------------------------------------------------|
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series 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 |
| ✅ | ✅ | View your library, downloads, blocklist, episodes |
| ✅ | ✅ | View details of a specific series, or episode including description, history, downloaded file info, or the credits |
| 🚫 | ✅ | View your host and security configs from the CLI to programmatically fetch the API token, among other settings |
| ✅ | ✅ | Search your library |
| ✅ | ✅ | Add series to your library |
| ✅ | ✅ | Delete series, downloads, indexers, root folders, and episode files |
| ✅ | ✅ | Trigger automatic searches for series, seasons, or episodes |
| ✅ | ✅ | Trigger refresh and disk scan for series and downloads |
| ✅ | ✅ | Manually search for series, seasons, or episodes |
| ✅ | ✅ | Edit your series 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 |
### 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 |
| ✅ | ✅ | 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
- [ ] Support for 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
@@ -245,7 +266,7 @@ To see all available commands, simply run `managarr --help`:
```shell
$ managarr --help
managarr 0.7.0
managarr 0.8.0
Alex Clarke <alex.j.tusa@gmail.com>
A TUI and CLI to manage your Servarrs
@@ -443,6 +464,11 @@ lidarr:
custom_headers: # Example of adding custom headers to all requests to the Servarr instance
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
```
### SSL Configuration
@@ -552,6 +578,46 @@ 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_DISABLE_SPINNER` | Disable the CLI spinner (this can be useful when scripting and parsing output) | `--disable-spinner` |
## Tested Readarr Forks
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
### Radarr
@@ -574,6 +640,13 @@ Managarr supports using environment variables on startup so you don't have to al
![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
![logs](screenshots/radarr/logs.png)
![indexers](screenshots/radarr/indexers.png)
@@ -5,3 +5,4 @@
# 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 = "𑛀"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 194 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

+31
View File
@@ -16,6 +16,7 @@ mod tests {
use crate::models::{HorizontallyScrollableText, TabRoute};
use crate::network::NetworkEvent;
use crate::network::radarr_network::RadarrEvent;
use crate::network::sonarr_network::SonarrEvent;
use tokio_util::sync::CancellationToken;
#[test]
@@ -165,6 +166,36 @@ mod tests {
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]
fn test_reset_cancellation_token() {
let mut app = App {
+3 -3
View File
@@ -498,7 +498,7 @@ mod tests {
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackDetails(1).into()
LidarrEvent::GetTrackDetails(5).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
@@ -518,7 +518,7 @@ mod tests {
assert!(app.is_loading);
assert_eq!(
rx.recv().await.unwrap(),
LidarrEvent::GetTrackHistory(1, 1, 1).into()
LidarrEvent::GetTrackHistory(1, 1, 5).into()
);
assert!(!app.data.lidarr_data.prompt_confirm);
assert_eq!(app.tick_count, 0);
@@ -759,7 +759,7 @@ mod tests {
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, 1);
assert_eq!(app.extract_track_id().await, 5);
}
#[tokio::test]
+5 -8
View File
@@ -263,14 +263,11 @@ impl App<'_> {
}
pub fn get_current_route(&self) -> Route {
*self.navigation_stack.last().unwrap_or(
&self
.server_tabs
.tabs
.first()
.expect("At least one server tab must exist")
.route,
)
self
.navigation_stack
.last()
.copied()
.unwrap_or_else(|| self.server_tabs.get_active_route())
}
}
+44
View File
@@ -9,7 +9,9 @@ mod tests {
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::{
Cli,
app::App,
@@ -24,6 +26,10 @@ mod tests {
BlocklistItem as RadarrBlocklistItem, BlocklistResponse as RadarrBlocklistResponse,
RadarrSerdeable,
},
readarr_models::{
BlocklistItem as ReadarrBlocklistItem, BlocklistResponse as ReadarrBlocklistResponse,
ReadarrSerdeable,
},
sonarr_models::{
BlocklistItem as SonarrBlocklistItem, BlocklistResponse as SonarrBlocklistResponse,
SonarrSerdeable,
@@ -68,6 +74,13 @@ mod tests {
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]
fn test_completions_requires_argument() {
let result = Cli::command().try_get_matches_from(["managarr", "completions"]);
@@ -218,4 +231,35 @@ mod tests {
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);
}
}
+1 -1
View File
@@ -631,7 +631,7 @@ mod tests {
async fn test_handle_list_track_history_command() {
let expected_artist_id = 1;
let expected_album_id = 1;
let expected_track_id = 1;
let expected_track_id = 5;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
+1
View File
@@ -171,6 +171,7 @@ impl<'a, 'b> CliCommandHandler<'a, 'b, ReadarrAddCommand> for ReadarrAddCommandH
quality_profile_id,
metadata_profile_id,
tags,
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor,
@@ -447,6 +447,7 @@ mod tests {
quality_profile_id: 1,
metadata_profile_id: 1,
tags: vec![1, 2],
images: Vec::new(),
tag_input_string: None,
add_options: AddAuthorOptions {
monitor: MonitorType::All,
+244 -3
View File
@@ -1,5 +1,20 @@
#[cfg(test)]
mod tests {
use crate::cli::{
Command,
readarr::{ReadarrCommand, list_command_handler::ReadarrListCommand},
};
use pretty_assertions::assert_eq;
#[test]
fn test_readarr_command_from() {
let command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = Command::from(command.clone());
assert_eq!(result, Command::Readarr(command));
}
mod cli {
use clap::CommandFactory;
use clap::error::ErrorKind;
@@ -243,20 +258,246 @@ mod tests {
use serde_json::json;
use tokio::sync::Mutex;
use crate::cli::readarr::add_command_handler::ReadarrAddCommand;
use crate::cli::readarr::edit_command_handler::ReadarrEditCommand;
use crate::cli::readarr::get_command_handler::ReadarrGetCommand;
use crate::cli::readarr::refresh_command_handler::ReadarrRefreshCommand;
use crate::cli::readarr::trigger_automatic_search_command_handler::ReadarrTriggerAutomaticSearchCommand;
use crate::models::readarr_models::{
BlocklistItem, BlocklistResponse, ReadarrSerdeable, ReadarrTaskName,
Author, BlocklistItem, BlocklistResponse, DeleteParams, ReadarrSerdeable, ReadarrTaskName,
};
use crate::models::servarr_models::ReleaseDownloadBody;
use crate::models::servarr_models::{IndexerSettings, ReleaseDownloadBody};
use crate::{
app::App,
cli::{
CliCommandHandler,
readarr::{ReadarrCliHandler, ReadarrCommand},
readarr::{
ReadarrCliHandler, ReadarrCommand, delete_command_handler::ReadarrDeleteCommand,
list_command_handler::ReadarrListCommand,
},
},
models::Serdeable,
network::{MockNetworkTrait, NetworkEvent, readarr_network::ReadarrEvent},
};
#[tokio::test]
async fn test_readarr_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>(
ReadarrEvent::AddTag(expected_tag_name.clone()).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let add_tag_command = ReadarrCommand::Add(ReadarrAddCommand::Tag {
name: expected_tag_name,
});
let result = ReadarrCliHandler::with(&app_arc, add_tag_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_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>(ReadarrEvent::GetStatus.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let get_system_status_command = ReadarrCommand::Get(ReadarrGetCommand::SystemStatus);
let result = ReadarrCliHandler::with(&app_arc, get_system_status_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_delete_commands_to_the_delete_command_handler() {
let expected_delete_author_params = DeleteParams {
id: 7,
delete_files: true,
add_import_list_exclusion: false,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::DeleteAuthor(expected_delete_author_params).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let delete_author_command = ReadarrCommand::Delete(ReadarrDeleteCommand::Author {
author_id: 7,
delete_files_from_disk: true,
add_list_exclusion: false,
});
let result = ReadarrCliHandler::with(&app_arc, delete_author_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_edit_commands_to_the_edit_command_handler() {
let expected_edit_all_indexer_settings = IndexerSettings {
id: 1,
maximum_size: 26500,
minimum_age: 17,
retention: 43,
rss_sync_interval: 35,
};
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::GetAllIndexerSettings.into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::IndexerSettings(
IndexerSettings {
id: 9,
maximum_size: 31200,
minimum_age: 22,
retention: 58,
rss_sync_interval: 90,
},
)))
});
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::EditAllIndexerSettings(expected_edit_all_indexer_settings).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let edit_all_indexer_settings_command =
ReadarrCommand::Edit(ReadarrEditCommand::AllIndexerSettings {
maximum_size: Some(26500),
minimum_age: Some(17),
retention: Some(43),
rss_sync_interval: Some(35),
});
let result = ReadarrCliHandler::with(
&app_arc,
edit_all_indexer_settings_command,
&mut mock_network,
)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_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>(ReadarrEvent::ListAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Authors(vec![
Author::default(),
])))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let list_authors_command = ReadarrCommand::List(ReadarrListCommand::Authors);
let result = ReadarrCliHandler::with(&app_arc, list_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_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>(ReadarrEvent::UpdateAllAuthors.into()))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let refresh_all_authors_command = ReadarrCommand::Refresh(ReadarrRefreshCommand::AllAuthors);
let result =
ReadarrCliHandler::with(&app_arc, refresh_all_authors_command, &mut mock_network)
.handle()
.await;
assert_ok!(&result);
}
#[tokio::test]
async fn test_readarr_cli_handler_delegates_trigger_automatic_search_commands_to_the_trigger_automatic_search_command_handler()
{
let expected_author_id = 7;
let mut mock_network = MockNetworkTrait::new();
mock_network
.expect_handle_network_event()
.with(eq::<NetworkEvent>(
ReadarrEvent::TriggerAutomaticAuthorSearch(expected_author_id).into(),
))
.times(1)
.returning(|_| {
Ok(Serdeable::Readarr(ReadarrSerdeable::Value(
json!({"testResponse": "response"}),
)))
});
let app_arc = Arc::new(Mutex::new(App::test_default()));
let trigger_automatic_search_command =
ReadarrCommand::TriggerAutomaticSearch(ReadarrTriggerAutomaticSearchCommand::Author {
author_id: expected_author_id,
});
let result = ReadarrCliHandler::with(
&app_arc,
trigger_automatic_search_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 = 1234i64;
+1 -1
View File
@@ -24,7 +24,7 @@ mod tests {
#[case(Key::Char('q'), "q")]
#[case(Key::Ctrl('q'), "ctrl-q")]
fn test_key_formatter(#[case] key: Key, #[case] expected_str: &str) {
assert_str_eq!(format!("{key}"), format!("{expected_str}"));
assert_str_eq!(format!("{key}"), expected_str.to_string());
}
#[test]
+61 -19
View File
@@ -3,13 +3,16 @@ mod property_tests {
use proptest::prelude::*;
use crate::app::App;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::event::Key;
use crate::handlers::handle_events;
use crate::handlers::handler_test_utils::test_utils::proptest_helpers::{
list_size, text_input_string,
};
use crate::models::radarr_models::Movie;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::stateful_table::StatefulTable;
use crate::models::{Paginated, Scrollable};
use crate::models::{Paginated, Scrollable, strip_non_search_characters};
proptest! {
#[test]
@@ -104,13 +107,29 @@ mod property_tests {
#[test]
fn test_string_input_safety(input in text_input_string()) {
let _lowercase = input.to_lowercase();
let _uppercase = input.to_uppercase();
let _trimmed = input.trim();
let _len = input.len();
let _chars: Vec<char> = input.chars().collect();
let mut app = App::test_default();
app.data.radarr_data.movies.set_items(vec![Movie::default()]);
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
handle_events(DEFAULT_KEYBINDINGS.filter.key, &mut app);
prop_assert!(true);
for character in input.chars() {
handle_events(Key::Char(character), &mut app);
}
prop_assert_eq!(app.get_current_route(), ActiveRadarrBlock::FilterMovies.into());
prop_assert_eq!(
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
input.as_str()
);
for _ in 0..input.chars().count() {
handle_events(DEFAULT_KEYBINDINGS.backspace.key, &mut app);
}
prop_assert_eq!(
app.data.radarr_data.movies.filter.as_ref().unwrap().text.as_str(),
""
);
}
#[test]
@@ -152,10 +171,19 @@ mod property_tests {
table.set_items(movies);
for i in 0..page_ops {
let previous_index = table.state.selected().unwrap();
if i % 2 == 0 {
table.page_down();
prop_assert_eq!(
table.state.selected(),
Some((previous_index + 20).min(list_size - 1))
);
} else {
table.page_up();
prop_assert_eq!(table.state.selected(), Some(previous_index.saturating_sub(20)));
}
let current = table.current_selection();
@@ -177,22 +205,36 @@ mod property_tests {
..Movie::default()
})
.collect();
table.set_items(movies.clone());
let original_size = table.items.len();
table.filter = Some(filter_term.clone().into());
let scrubbed_filter = strip_non_search_characters(&filter_term);
let matches_filter = |movie: &Movie| {
strip_non_search_characters(&movie.title.text).contains(&scrubbed_filter)
};
if !filter_term.is_empty() {
let filtered: Vec<Movie> = movies.into_iter()
.filter(|m| m.title.text.to_lowercase().contains(&filter_term.to_lowercase()))
.collect();
table.set_items(filtered);
}
let has_match = table.apply_filter(|movie| &movie.title.text);
prop_assert!(table.items.len() <= original_size);
prop_assert_eq!(&table.items, &movies);
prop_assert!(table.filter.is_none());
if !table.items.is_empty() {
let current = table.current_selection();
prop_assert!(current.id >= 0);
if has_match {
let filtered_items = table.filtered_items.as_ref().unwrap();
prop_assert!(!filtered_items.is_empty());
prop_assert!(filtered_items.len() <= movies.len());
for movie in filtered_items {
prop_assert!(matches_filter(movie));
}
for movie in movies.iter().filter(|movie| !filtered_items.contains(movie)) {
prop_assert!(!matches_filter(movie));
}
} else {
prop_assert!(table.filtered_items.is_none());
prop_assert!(
filter_term.is_empty() || movies.iter().all(|movie| !matches_filter(movie))
);
}
}
}
@@ -141,7 +141,7 @@ mod tests {
#[case(
ActiveLidarrBlock::Downloads,
ActiveLidarrBlock::DeleteDownloadPrompt,
LidarrEvent::DeleteDownload(1)
LidarrEvent::DeleteDownload(9)
)]
#[case(
ActiveLidarrBlock::Downloads,
@@ -338,7 +338,7 @@ mod tests {
#[case(
ActiveLidarrBlock::Downloads,
ActiveLidarrBlock::DeleteDownloadPrompt,
LidarrEvent::DeleteDownload(1)
LidarrEvent::DeleteDownload(9)
)]
#[case(
ActiveLidarrBlock::Downloads,
@@ -423,7 +423,7 @@ mod tests {
)
.extract_download_id();
assert_eq!(download_id, 1);
assert_eq!(download_id, 9);
}
#[test]
@@ -255,7 +255,7 @@ mod tests {
)]
#[case(
ActiveLidarrBlock::DeleteTrackFilePrompt,
LidarrEvent::DeleteTrackFile(1)
LidarrEvent::DeleteTrackFile(7)
)]
fn test_album_details_prompt_confirm_submit(
#[case] prompt_block: ActiveLidarrBlock,
@@ -606,7 +606,7 @@ mod tests {
)]
#[case(
ActiveLidarrBlock::DeleteTrackFilePrompt,
LidarrEvent::DeleteTrackFile(1)
LidarrEvent::DeleteTrackFile(7)
)]
fn test_album_details_prompt_confirm_confirm_key(
#[case] prompt_block: ActiveLidarrBlock,
@@ -752,7 +752,7 @@ mod tests {
)
.extract_track_file_id();
assert_eq!(track_file_id, 1);
assert_eq!(track_file_id, 7);
}
#[test]
@@ -768,7 +768,7 @@ mod tests {
mod test_handle_submit {
use crate::assert_navigation_popped;
use crate::handlers::radarr_handlers::radarr_handler_test_utils::utils::{
add_movie_body, add_movie_search_result, collection_movie,
add_movie_search_result, collection_movie,
};
use crate::models::BlockSelectionState;
use crate::models::radarr_models::Movie;
@@ -1336,7 +1336,7 @@ mod tests {
use crate::{
assert_navigation_popped,
handlers::radarr_handlers::radarr_handler_test_utils::utils::{
add_movie_body, add_movie_search_result, collection_movie,
add_movie_search_result, collection_movie,
},
models::{
BlockSelectionState,
@@ -5,10 +5,10 @@ pub(in crate::handlers::radarr_handlers) mod utils {
use crate::models::radarr_models::{
AddMovieBody, AddMovieOptions, AddMovieSearchResult, Collection, CollectionMovie,
DownloadRecord, IndexerSettings, MediaInfo, MinimumAvailability, Movie, MovieCollection,
MovieFile, MovieMonitor, RadarrRelease, Rating, RatingsList,
MovieFile, MovieMonitor, MovieStatus, RadarrRelease, Rating, RatingsList,
};
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
};
use chrono::DateTime;
use serde_json::{Number, json};
@@ -329,7 +329,7 @@ pub(in crate::handlers::radarr_handlers) mod utils {
title: "Test".to_owned().into(),
original_language: language(),
size_on_disk: 3543348019,
status: "Downloaded".to_owned(),
status: MovieStatus::Released,
overview: "Blah blah blah".to_owned(),
path: "/nfs/movies".to_owned(),
studio: Some("21st Century Alex".to_owned()),
@@ -395,7 +395,7 @@ pub(in crate::handlers::radarr_handlers) mod utils {
tmdb_id: 4321,
title: HorizontallyScrollableText::from("Test Search Result"),
original_language: language(),
status: "released".to_owned(),
status: MovieStatus::Released,
overview: "New movie blah blah blah".to_owned(),
genres: genres(),
year: 2023,
@@ -407,7 +407,7 @@ pub(in crate::handlers::radarr_handlers) mod utils {
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
movie_id: 1,
size: 3543348019,
@@ -11,8 +11,9 @@ mod tests {
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::readarr_handlers::downloads::DownloadsHandler;
use crate::models::readarr_models::{DownloadRecord, DownloadStatus};
use crate::models::readarr_models::DownloadRecord;
use crate::models::servarr_data::readarr::readarr_data::{ActiveReadarrBlock, DOWNLOADS_BLOCKS};
use crate::models::servarr_models::DownloadStatus;
mod test_handle_delete {
use pretty_assertions::assert_eq;
@@ -88,6 +88,7 @@ impl AddAuthorHandler<'_, '_> {
quality_profile_id,
metadata_profile_id,
tags: Vec::new(),
images: Vec::new(),
tag_input_string: Some(tags),
add_options: AddAuthorOptions {
monitor,
@@ -1237,6 +1237,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1628,6 +1629,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -1795,6 +1797,7 @@ mod tests {
quality_profile_id: 2222,
metadata_profile_id: 4444,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::Future,
@@ -140,7 +140,7 @@ mod tests {
#[case(
ActiveSonarrBlock::Downloads,
ActiveSonarrBlock::DeleteDownloadPrompt,
SonarrEvent::DeleteDownload(1)
SonarrEvent::DeleteDownload(9)
)]
#[case(
ActiveSonarrBlock::Downloads,
@@ -340,7 +340,7 @@ mod tests {
#[case(
ActiveSonarrBlock::Downloads,
ActiveSonarrBlock::DeleteDownloadPrompt,
SonarrEvent::DeleteDownload(1)
SonarrEvent::DeleteDownload(9)
)]
#[case(
ActiveSonarrBlock::Downloads,
@@ -431,7 +431,7 @@ mod tests {
)
.extract_download_id();
assert_eq!(download_id, 1);
assert_eq!(download_id, 9);
}
#[test]
@@ -207,7 +207,7 @@ mod tests {
#[rstest]
#[case(
ActiveSonarrBlock::AutomaticallySearchEpisodePrompt,
SonarrEvent::TriggerAutomaticEpisodeSearch(1)
SonarrEvent::TriggerAutomaticEpisodeSearch(11)
)]
fn test_episode_details_prompt_confirm_submit(
#[case] prompt_block: ActiveSonarrBlock,
@@ -646,7 +646,7 @@ mod tests {
assert_navigation_popped!(app, active_sonarr_block.into());
assert_some_eq_x!(
&app.data.sonarr_data.prompt_confirm_action,
&SonarrEvent::TriggerAutomaticEpisodeSearch(1)
&SonarrEvent::TriggerAutomaticEpisodeSearch(11)
);
}
@@ -787,7 +787,7 @@ mod tests {
)
.extract_episode_id();
assert_eq!(episode_id, 1);
assert_eq!(episode_id, 11);
}
#[test]
@@ -673,7 +673,7 @@ mod tests {
assert!(app.is_routing);
assert_some_eq_x!(
&app.data.sonarr_data.prompt_confirm_action,
&SonarrEvent::ToggleEpisodeMonitoring(1)
&SonarrEvent::ToggleEpisodeMonitoring(11)
);
}
@@ -1129,7 +1129,7 @@ mod tests {
)
.extract_episode_id();
assert_eq!(episode_id, 1);
assert_eq!(episode_id, 11);
}
#[test]
@@ -4,12 +4,12 @@ pub(in crate::handlers::sonarr_handlers) mod utils {
use crate::models::HorizontallyScrollableText;
use crate::models::servarr_models::IndexerSettings;
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
};
use crate::models::sonarr_models::{
AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, DownloadStatus,
Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics,
SeriesStatus, SeriesType,
AddSeriesSearchResult, AddSeriesSearchResultStatistics, DownloadRecord, Episode, EpisodeFile,
MediaInfo, Rating, Season, SeasonStatistics, Series, SeriesStatistics, SeriesStatus,
SeriesType,
};
use chrono::DateTime;
use serde_json::{Number, json};
@@ -191,8 +191,8 @@ pub(in crate::handlers::sonarr_handlers) mod utils {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
episode_id: Some(Number::from(1i64)),
id: 9,
episode_id: Some(Number::from(11i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from(
@@ -205,10 +205,10 @@ pub(in crate::handlers::sonarr_handlers) mod utils {
pub fn episode() -> Episode {
Episode {
id: 1,
id: 11,
series_id: 1,
tvdb_id: 1234,
episode_file_id: 1,
episode_file_id: 7,
season_number: 1,
episode_number: 1,
title: "Something cool".to_owned(),
@@ -224,7 +224,7 @@ pub(in crate::handlers::sonarr_handlers) mod utils {
pub fn episode_file() -> EpisodeFile {
EpisodeFile {
id: 1,
id: 7,
relative_path: "/season 1/episode 1.mkv".to_owned(),
path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(),
size: 3543348019,
+3 -2
View File
@@ -606,17 +606,18 @@ mod tests {
.take(100)
.collect();
app.data.radarr_data.movies.set_items(movies_vec);
app.data.radarr_data.movies.state.select(Some(50));
TableHandlerUnit::new(key, &mut app, ActiveRadarrBlock::Movies, None).handle();
if key == Key::PgUp {
assert_str_eq!(
app.data.radarr_data.movies.current_selection().title.text,
"Test 79"
"Test 30"
);
} else {
assert_str_eq!(
app.data.radarr_data.movies.current_selection().title.text,
"Test 20"
"Test 70"
);
}
}
+29 -2
View File
@@ -171,13 +171,40 @@ async fn main() -> Result<()> {
cancellation_token.clone(),
)));
if args.command.is_none()
&& let Some(name) = args.global.servarr_name.as_deref()
{
let mut app = app.lock().await;
anyhow::ensure!(
app.server_tabs.select_tab_by_title(name.trim()),
"A Servarr titled '{}' was not found in your configuration file",
name.trim()
);
}
match args.command {
Some(command) => match command {
Command::Radarr(_) | Command::Sonarr(_) | Command::Lidarr(_) | Command::Readarr(_) => {
if spinner_disabled {
start_cli_no_spinner(config, reqwest_client, cancellation_token, app, command).await;
start_cli_no_spinner(
config,
reqwest_client,
cancellation_token,
app,
command,
args.global.servarr_name.as_deref(),
)
.await;
} else {
start_cli_with_spinner(config, reqwest_client, cancellation_token, app, command).await;
start_cli_with_spinner(
config,
reqwest_client,
cancellation_token,
app,
command,
args.global.servarr_name.as_deref(),
)
.await;
}
}
Command::Completions { shell } => {
+7 -32
View File
@@ -1,8 +1,8 @@
use super::{
HorizontallyScrollableText, Serdeable,
servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile,
QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag,
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile,
QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag,
},
};
use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update};
@@ -27,6 +27,7 @@ pub struct Artist {
pub id: i64,
pub artist_name: HorizontallyScrollableText,
pub foreign_artist_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Artist {
#[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>,
pub tags: Vec<Number>,
@@ -161,6 +163,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -178,36 +181,6 @@ pub struct DownloadRecord {
impl Eq for DownloadRecord {}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
@@ -219,6 +192,7 @@ pub struct DownloadsResponse {
pub struct AddArtistSearchResult {
pub foreign_artist_id: String,
pub artist_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: ArtistStatus,
pub overview: Option<String>,
pub artist_type: Option<String>,
@@ -414,6 +388,7 @@ pub struct LidarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct LidarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: LidarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+112 -40
View File
@@ -2,23 +2,27 @@
mod tests {
use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord,
DownloadStatus, DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, MediaInfo, Member, MonitorType,
NewItemMonitorType, Track, TrackFile,
DownloadsResponse, LidarrHistoryEventType, LidarrHistoryItem, LidarrHistoryWrapper,
LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member, MonitorType, NewItemMonitorType,
Track, TrackFile,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse,
MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag,
Update,
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log,
LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig,
SystemStatus, Tag, Update,
};
use crate::models::{
Serdeable,
lidarr_models::{Artist, ArtistStatistics, ArtistStatus, LidarrSerdeable, Ratings},
};
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::{
ADD_ARTIST_SEARCH_RESULT_JSON, ARTIST_JSON, download_record, task,
};
#[test]
fn test_artist_status_default() {
@@ -655,40 +659,6 @@ mod tests {
assert_str_eq!(ArtistStatus::Deleted.to_display_str(), "Deleted");
}
#[test]
fn test_download_status_display() {
assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown");
assert_str_eq!(DownloadStatus::Queued.to_string(), "queued");
assert_str_eq!(DownloadStatus::Paused.to_string(), "paused");
assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading");
assert_str_eq!(DownloadStatus::Completed.to_string(), "completed");
assert_str_eq!(DownloadStatus::Failed.to_string(), "failed");
assert_str_eq!(DownloadStatus::Warning.to_string(), "warning");
assert_str_eq!(DownloadStatus::Delay.to_string(), "delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_string(),
"downloadClientUnavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback");
}
#[test]
fn test_download_status_to_display_str() {
assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown");
assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued");
assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused");
assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading");
assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed");
assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed");
assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning");
assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_display_str(),
"Download Client Unavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_lidarr_history_event_type_display() {
assert_str_eq!(LidarrHistoryEventType::Unknown.to_string(), "unknown");
@@ -946,6 +916,108 @@ mod tests {
assert_none!(&search_result.ratings);
}
#[test]
fn test_artist_status_deserialization_falls_back_to_default() {
let expected = Artist {
status: ArtistStatus::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_artist_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Artist {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(ARTIST_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Artist>(ARTIST_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_artist_search_result_status_deserialization_falls_back_to_default() {
let expected = AddArtistSearchResult {
status: ArtistStatus::default(),
..serde_json::from_str(ADD_ARTIST_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddArtistSearchResult>(
ADD_ARTIST_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_lidarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = LidarrTask {
task_name: LidarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<LidarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> LidarrHistoryEventType {
let history_item_json = json!({
"id": 1,
+38 -6
View File
@@ -12,8 +12,8 @@ use strum_macros::{Display, EnumIter};
use super::Serdeable;
use super::servarr_models::{
DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper,
QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update,
DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile,
QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update,
};
#[cfg(test)]
@@ -42,7 +42,8 @@ pub struct AddMovieSearchResult {
pub tmdb_id: i64,
pub title: HorizontallyScrollableText,
pub original_language: Language,
pub status: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: MovieStatus,
pub overview: String,
pub genres: Vec<String>,
#[serde(deserialize_with = "super::from_i64")]
@@ -98,6 +99,7 @@ pub struct Collection {
pub search_on_add: bool,
pub monitored: bool,
pub overview: Option<String>,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability,
#[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64,
@@ -126,7 +128,7 @@ pub struct Credit {
pub character: Option<String>,
pub department: Option<String>,
pub job: Option<String>,
#[serde(rename(deserialize = "type"))]
#[serde(rename = "type", deserialize_with = "super::from_json_or_default")]
pub credit_type: CreditType,
}
@@ -150,7 +152,8 @@ pub struct DeleteMovieParams {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
pub status: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
#[serde(deserialize_with = "super::from_i64")]
@@ -301,7 +304,8 @@ pub struct Movie {
pub original_language: Language,
#[serde(deserialize_with = "super::from_i64")]
pub size_on_disk: i64,
pub status: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: MovieStatus,
pub overview: String,
pub path: String,
pub studio: Option<String>,
@@ -316,6 +320,7 @@ pub struct Movie {
pub tmdb_id: i64,
#[serde(deserialize_with = "super::from_i64")]
pub quality_profile_id: i64,
#[serde(deserialize_with = "super::from_json_or_default")]
pub minimum_availability: MinimumAvailability,
pub certification: Option<String>,
pub tags: Vec<Number>,
@@ -324,6 +329,32 @@ pub struct Movie {
pub collection: Option<MovieCollection>,
}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum MovieStatus {
#[default]
#[display_style(name = "TBA")]
Tba,
Announced,
#[display_style(name = "In Cinemas")]
InCinemas,
Released,
Deleted,
}
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct MovieCollection {
@@ -469,6 +500,7 @@ pub struct RadarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct RadarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: RadarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+185 -7
View File
@@ -1,6 +1,7 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::radarr_models::{
@@ -9,15 +10,19 @@ mod tests {
use crate::models::{
Serdeable,
radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, DiskSpace,
DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, QualityProfile, RadarrRelease,
RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update,
AddMovieSearchResult, BlocklistItem, BlocklistResponse, Collection, Credit, CreditType,
DiskSpace, DownloadRecord, DownloadsResponse, Indexer, IndexerSettings, IndexerTestResult,
MinimumAvailability, Movie, MovieHistoryItem, MovieMonitor, MovieStatus, QualityProfile,
RadarrRelease, RadarrSerdeable, RadarrTask, RadarrTaskName, Tag, Update,
},
servarr_models::{
HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig, SystemStatus,
DownloadStatus, HostConfig, Log, LogResponse, QueueEvent, RootFolder, SecurityConfig,
SystemStatus,
},
};
use crate::network::radarr_network::radarr_network_test_utils::test_utils::{
MOVIE_JSON, add_movie_search_result, collection, crew_credit, download_record, task,
};
#[test]
fn test_task_name_display() {
@@ -46,6 +51,24 @@ mod tests {
assert_str_eq!(MinimumAvailability::Released.to_display_str(), "Released");
}
#[test]
fn test_movie_status_display() {
assert_str_eq!(MovieStatus::Tba.to_string(), "tba");
assert_str_eq!(MovieStatus::Announced.to_string(), "announced");
assert_str_eq!(MovieStatus::InCinemas.to_string(), "inCinemas");
assert_str_eq!(MovieStatus::Released.to_string(), "released");
assert_str_eq!(MovieStatus::Deleted.to_string(), "deleted");
}
#[test]
fn test_movie_status_to_display_str() {
assert_str_eq!(MovieStatus::Tba.to_display_str(), "TBA");
assert_str_eq!(MovieStatus::Announced.to_display_str(), "Announced");
assert_str_eq!(MovieStatus::InCinemas.to_display_str(), "In Cinemas");
assert_str_eq!(MovieStatus::Released.to_display_str(), "Released");
assert_str_eq!(MovieStatus::Deleted.to_display_str(), "Deleted");
}
#[test]
fn test_monitor_display() {
assert_str_eq!(MovieMonitor::MovieOnly.to_string(), "movieOnly");
@@ -222,11 +245,23 @@ mod tests {
);
}
#[test]
fn test_credit_serializes_credit_type_under_the_type_key() {
let credit_json = serde_json::to_value(crew_credit()).unwrap();
assert_eq!(credit_json["type"], json!("crew"));
assert_none!(credit_json.get("creditType"));
assert_eq!(
serde_json::from_value::<Credit>(credit_json).unwrap(),
crew_credit()
);
}
#[test]
fn test_download_record_default_indexer_value() {
let json = r#"{
"title": "test",
"status": "test",
"status": "downloading",
"id": 0,
"movieId": 0,
"size": 0,
@@ -235,7 +270,7 @@ mod tests {
}"#;
let expected_record = DownloadRecord {
title: "test".to_owned(),
status: "test".to_owned(),
status: DownloadStatus::Downloading,
id: 0,
movie_id: 0,
size: 0,
@@ -614,6 +649,149 @@ mod tests {
);
}
#[test]
fn test_movie_minimum_availability_deserialization_falls_back_to_default() {
let expected = Movie {
minimum_availability: MinimumAvailability::default(),
..serde_json::from_str(MOVIE_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "minimumAvailability", json!("notAThing")),
expected
);
}
#[test]
fn test_movie_status_deserialization_falls_back_to_default() {
let expected = Movie {
status: MovieStatus::default(),
..serde_json::from_str(MOVIE_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Movie>(MOVIE_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_add_movie_search_result_status_deserialization_falls_back_to_default() {
let add_movie_search_result_json = serde_json::to_string(&add_movie_search_result()).unwrap();
let expected = AddMovieSearchResult {
status: MovieStatus::default(),
..add_movie_search_result()
};
assert_eq!(
deserialize_with_field::<AddMovieSearchResult>(
&add_movie_search_result_json,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddMovieSearchResult>(
&add_movie_search_result_json,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_collection_minimum_availability_deserialization_falls_back_to_default() {
let collection_json = serde_json::to_string(&collection()).unwrap();
let expected = Collection {
minimum_availability: MinimumAvailability::default(),
..collection()
};
assert_eq!(
deserialize_with_field::<Collection>(&collection_json, "minimumAvailability", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Collection>(
&collection_json,
"minimumAvailability",
json!("notAThing")
),
expected
);
}
#[test]
fn test_credit_credit_type_deserialization_falls_back_to_default() {
let credit_json = serde_json::to_string(&crew_credit()).unwrap();
let expected = Credit {
credit_type: CreditType::default(),
..crew_credit()
};
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Credit>(&credit_json, "type", json!("notAThing")),
expected
);
}
#[test]
fn test_radarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = RadarrTask {
task_name: RadarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<RadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> RadarrHistoryEventType {
let history_item_json = json!({
"id": 1,
+33 -32
View File
@@ -1,8 +1,8 @@
use super::{
HorizontallyScrollableText, Serdeable,
servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerTestResult, MetadataProfile, QualityProfile,
QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag,
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerTestResult, MetadataProfile,
QualityProfile, QualityWrapper, RootFolder, SecurityConfig, SystemStatus, Tag,
},
};
use crate::models::servarr_models::{IndexerSettings, LogResponse, QueueEvent, Update};
@@ -27,6 +27,7 @@ pub struct Author {
pub id: i64,
pub author_name: HorizontallyScrollableText,
pub foreign_author_id: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus,
pub overview: Option<String>,
pub author_type: Option<String>,
@@ -38,6 +39,7 @@ pub struct Author {
#[serde(deserialize_with = "super::from_i64")]
pub metadata_profile_id: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub monitor_new_items: NewItemMonitorType,
pub genres: Vec<String>,
pub tags: Vec<Number>,
@@ -152,6 +154,7 @@ pub enum MonitorType {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -169,36 +172,6 @@ pub struct DownloadRecord {
impl Eq for DownloadRecord {}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
@@ -210,6 +183,7 @@ pub struct DownloadsResponse {
pub struct AddAuthorSearchResult {
pub foreign_author_id: String,
pub author_name: HorizontallyScrollableText,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: AuthorStatus,
#[serde(default)]
pub ended: bool,
@@ -248,6 +222,8 @@ pub struct AddAuthorBody {
pub quality_profile_id: i64,
pub metadata_profile_id: i64,
pub tags: Vec<i64>,
// Readarr-Resurrected persists the add body directly and rejects it when the images list is absent
pub images: Vec<Value>,
#[serde(skip_serializing, skip_deserializing)]
pub tag_input_string: Option<String>,
pub add_options: AddAuthorOptions,
@@ -306,6 +282,7 @@ pub struct Book {
pub ratings: Option<Ratings>,
pub release_date: Option<DateTime<Utc>>,
pub statistics: Option<BookStatistics>,
#[serde(default)]
pub grabbed: bool,
}
@@ -423,6 +400,7 @@ pub struct ReadarrHistoryItem {
#[serde(rename_all = "camelCase")]
pub struct ReadarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: ReadarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
@@ -479,6 +457,29 @@ pub struct ReadarrRelease {
pub quality: QualityWrapper,
}
#[derive(Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(untagged)]
pub enum ReadarrReleaseResponse {
Releases(Vec<ReadarrRelease>),
// Chaptarr wraps the release list in a filter summary envelope, even behind its Readarr facade
Envelope { releases: Vec<ReadarrRelease> },
}
impl Default for ReadarrReleaseResponse {
fn default() -> Self {
ReadarrReleaseResponse::Releases(Vec::new())
}
}
impl ReadarrReleaseResponse {
pub fn into_releases(self) -> Vec<ReadarrRelease> {
match self {
ReadarrReleaseResponse::Releases(releases)
| ReadarrReleaseResponse::Envelope { releases } => releases,
}
}
}
#[derive(Default, Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct BlocklistItem {
+155 -39
View File
@@ -2,23 +2,27 @@
mod tests {
use chrono::Utc;
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::readarr_models::{
AddAuthorSearchResult, BlocklistItem, BlocklistResponse, Book, BookFile, DownloadRecord,
DownloadStatus, DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType,
DownloadsResponse, Edition, MediaInfo, MonitorType, NewItemMonitorType,
ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrHistoryWrapper, ReadarrRelease,
ReadarrTask, ReadarrTaskName,
ReadarrReleaseResponse, ReadarrTask, ReadarrTaskName,
};
use crate::models::servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log, LogResponse,
MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag,
Update,
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Log,
LogResponse, MetadataProfile, QualityProfile, QueueEvent, RootFolder, SecurityConfig,
SystemStatus, Tag, Update,
};
use crate::models::{
Serdeable,
readarr_models::{Author, AuthorStatistics, AuthorStatus, Ratings, ReadarrSerdeable},
};
use crate::network::readarr_network::readarr_network_test_utils::test_utils::{
ADD_AUTHOR_SEARCH_RESULT_JSON, AUTHOR_JSON, download_record,
};
#[test]
fn test_author_status_default() {
@@ -493,6 +497,29 @@ mod tests {
assert_eq!(readarr_serdeable, ReadarrSerdeable::Releases(releases));
}
#[test]
fn test_readarr_release_response_default_is_an_empty_release_list() {
assert_eq!(
ReadarrReleaseResponse::default(),
ReadarrReleaseResponse::Releases(Vec::new())
);
}
#[test]
fn test_readarr_release_response_into_releases() {
let releases = vec![ReadarrRelease {
guid: "test".to_owned(),
..ReadarrRelease::default()
}];
let list = ReadarrReleaseResponse::Releases(releases.clone());
let envelope = ReadarrReleaseResponse::Envelope {
releases: releases.clone(),
};
assert_eq!(list.into_releases(), releases);
assert_eq!(envelope.into_releases(), releases);
}
#[test]
fn test_readarr_serdeable_from_security_config() {
let security_config = SecurityConfig {
@@ -640,40 +667,6 @@ mod tests {
assert_str_eq!(AuthorStatus::Deleted.to_display_str(), "Deleted");
}
#[test]
fn test_download_status_display() {
assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown");
assert_str_eq!(DownloadStatus::Queued.to_string(), "queued");
assert_str_eq!(DownloadStatus::Paused.to_string(), "paused");
assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading");
assert_str_eq!(DownloadStatus::Completed.to_string(), "completed");
assert_str_eq!(DownloadStatus::Failed.to_string(), "failed");
assert_str_eq!(DownloadStatus::Warning.to_string(), "warning");
assert_str_eq!(DownloadStatus::Delay.to_string(), "delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_string(),
"downloadClientUnavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback");
}
#[test]
fn test_download_status_to_display_str() {
assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown");
assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued");
assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused");
assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading");
assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed");
assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed");
assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning");
assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_display_str(),
"Download Client Unavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_readarr_history_event_type_display() {
assert_str_eq!(ReadarrHistoryEventType::Unknown.to_string(), "unknown");
@@ -969,6 +962,22 @@ mod tests {
assert_none!(&book.statistics);
}
#[test]
fn test_book_deserialization_defaults_grabbed_when_absent() {
let book_json = json!({
"id": 1,
"title": "The Name of the Wind",
"authorId": 3,
"foreignBookId": "test-foreign-book-id",
"monitored": true
});
let book: Book = serde_json::from_value(book_json).unwrap();
assert!(!book.grabbed);
assert!(book.monitored);
}
#[test]
fn test_edition_deserialization() {
let edition_json = json!({
@@ -1140,6 +1149,113 @@ mod tests {
assert_none!(&search_result.ratings);
}
#[test]
fn test_author_status_deserialization_falls_back_to_default() {
let expected = Author {
status: AuthorStatus::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_author_monitor_new_items_deserialization_falls_back_to_default() {
let expected = Author {
monitor_new_items: NewItemMonitorType::default(),
..serde_json::from_str(AUTHOR_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Author>(AUTHOR_JSON, "monitorNewItems", json!("notAThing")),
expected
);
}
#[test]
fn test_add_author_search_result_status_deserialization_falls_back_to_default() {
let expected = AddAuthorSearchResult {
status: AuthorStatus::default(),
..serde_json::from_str(ADD_AUTHOR_SEARCH_RESULT_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<AddAuthorSearchResult>(
ADD_AUTHOR_SEARCH_RESULT_JSON,
"status",
json!("notAThing")
),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_readarr_task_task_name_deserialization_falls_back_to_default() {
let task = ReadarrTask {
name: "Backup".to_owned(),
task_name: ReadarrTaskName::Backup,
..ReadarrTask::default()
};
let task_json = serde_json::to_string(&task).unwrap();
let expected = ReadarrTask {
task_name: ReadarrTaskName::default(),
..task
};
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<ReadarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> ReadarrHistoryEventType {
let history_item_json = json!({
"id": 1,
@@ -31,10 +31,10 @@ use strum::EnumIter;
use {
super::modals::EditionDetailsModal,
crate::models::readarr_models::{
AuthorStatus, BookFile, DownloadStatus, Edition, MediaInfo, MonitorType, NewItemMonitorType,
AuthorStatus, BookFile, BookStatistics, Edition, MediaInfo, MonitorType, NewItemMonitorType,
Ratings, ReadarrHistoryEventType, ReadarrTaskName,
},
crate::models::servarr_models::{IndexerField, Quality, QualityWrapper},
crate::models::servarr_models::{DownloadStatus, IndexerField, Quality, QualityWrapper},
crate::models::stateful_table::SortOption,
crate::network::servarr_test_utils::{
diskspace, indexer_settings, indexer_test_result, queued_event,
@@ -292,6 +292,12 @@ impl ReadarrData<'_> {
any_edition_ok: true,
page_count: Some(288),
release_date: Some(date),
statistics: Some(BookStatistics {
book_file_count: 3,
total_book_count: 7,
size_on_disk: 2457600,
percent_of_books: 42.86,
}),
..Book::default()
};
let readarr_history_item = ReadarrHistoryItem {
@@ -532,7 +538,7 @@ impl ReadarrData<'_> {
author_id: 1,
book_id: 1,
path: "/nfs/books/Test Author/Test Book.epub".to_owned(),
size: 3543348019,
size: 3355443,
date_added: date,
quality: quality.clone(),
media_info: Some(MediaInfo {
+35
View File
@@ -2,8 +2,10 @@ use std::fmt::{Display, Formatter, Result};
use chrono::{DateTime, Utc};
use clap::ValueEnum;
use enum_display_style_derive::EnumDisplayStyle;
use serde::{Deserialize, Serialize};
use serde_json::{Number, Value};
use strum::{Display, EnumIter};
use super::HorizontallyScrollableText;
@@ -90,6 +92,36 @@ pub struct DiskSpace {
pub total_space: i64,
}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct IndexerSettings {
@@ -315,7 +347,9 @@ pub struct RootFolder {
#[derive(Default, Serialize, Deserialize, Clone, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SecurityConfig {
#[serde(deserialize_with = "super::from_json_or_default")]
pub authentication_method: AuthenticationMethod,
#[serde(default, deserialize_with = "super::from_json_or_default")]
#[serde(skip_serializing_if = "Option::is_none")]
pub authentication_required: Option<AuthenticationRequired>,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -323,6 +357,7 @@ pub struct SecurityConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub password: Option<String>,
pub api_key: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub certificate_validation: CertificateValidation,
}
+152 -2
View File
@@ -1,10 +1,12 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::servarr_models::{
AuthenticationMethod, AuthenticationRequired, CertificateValidation, Indexer, QualityProfile,
Update, UpdateChanges,
AuthenticationMethod, AuthenticationRequired, CertificateValidation, DownloadStatus, Indexer,
QualityProfile, SecurityConfig, Update, UpdateChanges,
};
#[test]
@@ -54,6 +56,40 @@ mod tests {
assert_str_eq!(CertificateValidation::Disabled.to_string(), "disabled");
}
#[test]
fn test_download_status_display() {
assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown");
assert_str_eq!(DownloadStatus::Queued.to_string(), "queued");
assert_str_eq!(DownloadStatus::Paused.to_string(), "paused");
assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading");
assert_str_eq!(DownloadStatus::Completed.to_string(), "completed");
assert_str_eq!(DownloadStatus::Failed.to_string(), "failed");
assert_str_eq!(DownloadStatus::Warning.to_string(), "warning");
assert_str_eq!(DownloadStatus::Delay.to_string(), "delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_string(),
"downloadClientUnavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback");
}
#[test]
fn test_download_status_to_display_str() {
assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown");
assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued");
assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused");
assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading");
assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed");
assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed");
assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning");
assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_display_str(),
"Download Client Unavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_quality_profile_from_tuple_ref() {
let id = 2;
@@ -90,4 +126,118 @@ mod tests {
}
);
}
#[test]
fn test_security_config_authentication_method_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_method: AuthenticationMethod::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationMethod",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_authentication_required_deserialization_falls_back_to_none() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"authenticationRequired",
json!("notAThing")
),
expected
);
}
#[test]
fn test_security_config_deserializes_when_authentication_required_is_absent() {
let mut security_config_json = serde_json::to_value(security_config()).unwrap();
security_config_json
.as_object_mut()
.unwrap()
.remove("authenticationRequired");
let expected = SecurityConfig {
authentication_required: None,
..security_config()
};
let deserialized = serde_json::from_value::<SecurityConfig>(security_config_json);
assert_ok!(&deserialized);
assert_eq!(deserialized.unwrap(), expected);
}
#[test]
fn test_security_config_certificate_validation_deserialization_falls_back_to_default() {
let security_config_json = serde_json::to_string(&security_config()).unwrap();
let expected = SecurityConfig {
certificate_validation: CertificateValidation::default(),
..security_config()
};
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!(2)
),
expected
);
assert_eq!(
deserialize_with_field::<SecurityConfig>(
&security_config_json,
"certificateValidation",
json!("notAThing")
),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn security_config() -> SecurityConfig {
SecurityConfig {
authentication_method: AuthenticationMethod::Forms,
authentication_required: Some(AuthenticationRequired::Enabled),
username: Some("admin".to_owned()),
password: Some("password".to_owned()),
api_key: "test-api-key".to_owned(),
certificate_validation: CertificateValidation::Disabled,
}
}
}
+6 -32
View File
@@ -16,8 +16,8 @@ use strum_macros::Display;
use super::{
HorizontallyScrollableText, Serdeable,
servarr_models::{
DiskSpace, HostConfig, Indexer, Language, LogResponse, QualityProfile, QualityWrapper,
QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update,
DiskSpace, DownloadStatus, HostConfig, Indexer, Language, LogResponse, QualityProfile,
QualityWrapper, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag, Update,
},
};
@@ -112,6 +112,7 @@ pub struct DeleteSeriesParams {
#[serde(rename_all = "camelCase")]
pub struct DownloadRecord {
pub title: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: DownloadStatus,
#[serde(deserialize_with = "super::from_i64")]
pub id: i64,
@@ -128,36 +129,6 @@ pub struct DownloadRecord {
impl Eq for DownloadRecord {}
#[derive(
Serialize,
Deserialize,
Default,
PartialEq,
Eq,
Clone,
Copy,
Debug,
EnumIter,
Display,
EnumDisplayStyle,
)]
#[serde(rename_all = "camelCase")]
#[strum(serialize_all = "camelCase")]
pub enum DownloadStatus {
#[default]
Unknown,
Queued,
Paused,
Downloading,
Completed,
Failed,
Warning,
Delay,
#[display_style(name = "Download Client Unavailable")]
DownloadClientUnavailable,
Fallback,
}
#[derive(Default, Clone, Serialize, Deserialize, Debug, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct DownloadsResponse {
@@ -314,12 +285,14 @@ pub struct Series {
#[serde(deserialize_with = "super::from_i64")]
pub year: i64,
pub monitored: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub series_type: SeriesType,
pub path: String,
pub genres: Vec<String>,
pub tags: Vec<Number>,
pub ratings: Rating,
pub ended: bool,
#[serde(deserialize_with = "super::from_json_or_default")]
pub status: SeriesStatus,
pub overview: Option<String>,
pub network: Option<String>,
@@ -556,6 +529,7 @@ pub struct SonarrReleaseDownloadBody {
#[serde(rename_all = "camelCase")]
pub struct SonarrTask {
pub name: String,
#[serde(deserialize_with = "super::from_json_or_default")]
pub task_name: SonarrTaskName,
#[serde(deserialize_with = "super::from_i64")]
pub interval: i64,
+85 -38
View File
@@ -1,22 +1,26 @@
#[cfg(test)]
mod tests {
use pretty_assertions::{assert_eq, assert_str_eq};
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use crate::models::{
Serdeable,
servarr_models::{
DiskSpace, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language, Log,
LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag,
DiskSpace, DownloadStatus, HostConfig, Indexer, IndexerSettings, IndexerTestResult, Language,
Log, LogResponse, QualityProfile, QueueEvent, RootFolder, SecurityConfig, SystemStatus, Tag,
Update,
},
sonarr_models::{
AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus,
DownloadsResponse, Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType,
AddSeriesSearchResult, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse,
Episode, EpisodeFile, Series, SeriesMonitor, SeriesStatus, SeriesType,
SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrSerdeable, SonarrTask,
SonarrTaskName,
},
};
use crate::network::sonarr_network::sonarr_network_test_utils::test_utils::{
SERIES_JSON, download_record, task,
};
#[test]
fn test_episode_display() {
@@ -119,40 +123,6 @@ mod tests {
assert_str_eq!(SeriesType::Anime.to_display_str(), "Anime");
}
#[test]
fn test_download_status_display() {
assert_str_eq!(DownloadStatus::Unknown.to_string(), "unknown");
assert_str_eq!(DownloadStatus::Queued.to_string(), "queued");
assert_str_eq!(DownloadStatus::Paused.to_string(), "paused");
assert_str_eq!(DownloadStatus::Downloading.to_string(), "downloading");
assert_str_eq!(DownloadStatus::Completed.to_string(), "completed");
assert_str_eq!(DownloadStatus::Failed.to_string(), "failed");
assert_str_eq!(DownloadStatus::Warning.to_string(), "warning");
assert_str_eq!(DownloadStatus::Delay.to_string(), "delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_string(),
"downloadClientUnavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_string(), "fallback");
}
#[test]
fn test_download_status_to_display_str() {
assert_str_eq!(DownloadStatus::Unknown.to_display_str(), "Unknown");
assert_str_eq!(DownloadStatus::Queued.to_display_str(), "Queued");
assert_str_eq!(DownloadStatus::Paused.to_display_str(), "Paused");
assert_str_eq!(DownloadStatus::Downloading.to_display_str(), "Downloading");
assert_str_eq!(DownloadStatus::Completed.to_display_str(), "Completed");
assert_str_eq!(DownloadStatus::Failed.to_display_str(), "Failed");
assert_str_eq!(DownloadStatus::Warning.to_display_str(), "Warning");
assert_str_eq!(DownloadStatus::Delay.to_display_str(), "Delay");
assert_str_eq!(
DownloadStatus::DownloadClientUnavailable.to_display_str(),
"Download Client Unavailable"
);
assert_str_eq!(DownloadStatus::Fallback.to_display_str(), "Fallback");
}
#[test]
fn test_sonarr_history_event_type_display() {
assert_str_eq!(SonarrHistoryEventType::Unknown.to_string(), "unknown",);
@@ -698,6 +668,83 @@ mod tests {
);
}
#[test]
fn test_series_series_type_deserialization_falls_back_to_default() {
let expected = Series {
series_type: SeriesType::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "seriesType", json!("notAThing")),
expected
);
}
#[test]
fn test_series_status_deserialization_falls_back_to_default() {
let expected = Series {
status: SeriesStatus::default(),
..serde_json::from_str(SERIES_JSON).unwrap()
};
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<Series>(SERIES_JSON, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_download_record_status_deserialization_falls_back_to_default() {
let download_record_json = serde_json::to_string(&download_record()).unwrap();
let expected = DownloadRecord {
status: DownloadStatus::default(),
..download_record()
};
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<DownloadRecord>(&download_record_json, "status", json!("notAThing")),
expected
);
}
#[test]
fn test_sonarr_task_task_name_deserialization_falls_back_to_default() {
let task_json = serde_json::to_string(&task()).unwrap();
let expected = SonarrTask {
task_name: SonarrTaskName::default(),
..task()
};
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!(2)),
expected
);
assert_eq!(
deserialize_with_field::<SonarrTask>(&task_json, "taskName", json!("notAThing")),
expected
);
}
fn deserialize_with_field<T: DeserializeOwned>(json: &str, field: &str, value: Value) -> T {
let mut fixture_json: Value = serde_json::from_str(json).unwrap();
fixture_json[field] = value;
serde_json::from_value(fixture_json).unwrap()
}
fn deserialize_history_event_type(event_type: Value) -> SonarrHistoryEventType {
let history_item_json = json!({
"id": 1,
+4 -7
View File
@@ -100,29 +100,26 @@ where
{
fn page_down(&mut self) {
let items_len = self.active_items().len();
if items_len <= 1 {
if items_len == 0 {
return;
}
let state = self.active_state_mut();
match state.selected() {
Some(i) => state.select(Some(i.saturating_add(20) % (items_len - 1))),
Some(i) => state.select(Some(i.saturating_add(20).min(items_len - 1))),
None => state.select_first(),
}
}
fn page_up(&mut self) {
let items_len = self.active_items().len();
if items_len <= 1 {
if items_len == 0 {
return;
}
let state = self.active_state_mut();
match state.selected() {
Some(i) => {
let len = items_len - 1;
state.select(Some((i + len - (20 % len)) % len));
}
Some(i) => state.select(Some(i.saturating_sub(20))),
None => state.select_last(),
}
}
+150 -38
View File
@@ -318,6 +318,69 @@ mod tests {
20
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
40
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
60
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
80
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
99
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
99
);
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
79
);
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
59
);
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
39
);
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
19
);
stateful_table.page_up();
assert_some_eq_x!(
@@ -327,33 +390,10 @@ mod tests {
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
stateful_table.filtered_items.as_ref().unwrap().len() - 21
);
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
0
);
stateful_table.scroll_down();
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
stateful_table.filtered_items.as_ref().unwrap().len() - 20
);
stateful_table.scroll_down();
stateful_table.page_down();
assert_some_eq_x!(
stateful_table.filtered_state.as_ref().unwrap().selected(),
2
);
}
#[test]
@@ -376,33 +416,105 @@ mod tests {
assert_some_eq_x!(stateful_table.state.selected(), 20);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 40);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 60);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 80);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 99);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 99);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 79);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 59);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 39);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 19);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 0);
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.state.selected(),
stateful_table.items.len() - 21
);
assert_some_eq_x!(stateful_table.state.selected(), 0);
}
stateful_table.page_down();
#[test]
fn test_stateful_table_pagination_on_twenty_one_item_table_stops_at_last_item() {
let mut stateful_table = StatefulTable::default();
stateful_table.set_items((0..21).collect());
assert_some_eq_x!(stateful_table.state.selected(), 0);
stateful_table.scroll_down();
stateful_table.page_up();
assert_some_eq_x!(
stateful_table.state.selected(),
stateful_table.items.len() - 20
);
stateful_table.scroll_down();
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 2);
assert_some_eq_x!(stateful_table.state.selected(), 20);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 20);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 0);
}
#[test]
fn test_stateful_table_pagination_on_five_item_table_stops_at_last_item() {
let mut stateful_table = StatefulTable::default();
stateful_table.set_items((0..5).collect());
assert_some_eq_x!(stateful_table.state.selected(), 0);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 4);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 4);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 0);
}
#[test]
fn test_stateful_table_pagination_on_four_item_table_reaches_last_item() {
let mut stateful_table = StatefulTable::default();
stateful_table.set_items((0..4).collect());
assert_some_eq_x!(stateful_table.state.selected(), 0);
stateful_table.page_down();
assert_some_eq_x!(stateful_table.state.selected(), 3);
stateful_table.page_up();
assert_some_eq_x!(stateful_table.state.selected(), 0);
}
#[test]
@@ -34,7 +34,7 @@ mod tests {
"trackFileCount": 8,
"trackCount": 10,
"totalTrackCount": 10,
"sizeOnDisk": 1024,
"sizeOnDisk": 317162578,
"percentOfTracks": 99.9
}
}]);
@@ -3,15 +3,15 @@
pub mod test_utils {
use crate::models::lidarr_models::{
AddArtistSearchResult, Album, AlbumStatistics, Artist, ArtistStatistics, ArtistStatus,
AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadStatus, DownloadsResponse,
AudioTags, BlocklistItem, BlocklistResponse, DownloadRecord, DownloadsResponse,
EditArtistParams, LidarrHistoryData, LidarrHistoryEventType, LidarrHistoryItem,
LidarrHistoryWrapper, LidarrRelease, LidarrTask, LidarrTaskName, MediaInfo, Member,
NewItemMonitorType, Ratings, Track, TrackFile,
};
use crate::models::servarr_models::IndexerSettings;
use crate::models::servarr_models::{
Indexer, IndexerField, MetadataProfile, Quality, QualityProfile, QualityWrapper, RootFolder,
SystemStatus, Tag,
DownloadStatus, Indexer, IndexerField, MetadataProfile, Quality, QualityProfile,
QualityWrapper, RootFolder, SystemStatus, Tag,
};
use crate::models::{HorizontallyScrollableText, ScrollableText};
use bimap::BiMap;
@@ -74,7 +74,7 @@ pub mod test_utils {
"trackFileCount": 8,
"trackCount": 10,
"totalTrackCount": 10,
"sizeOnDisk": 1024,
"sizeOnDisk": 317162578,
"percentOfTracks": 99.9
}
}"#;
@@ -184,9 +184,9 @@ pub mod test_utils {
DownloadRecord {
title: "Test download title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
id: 9,
album_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(1i64)),
artist_id: Some(Number::from(3i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from("/nfs/music/alex/album")),
@@ -250,7 +250,7 @@ pub mod test_utils {
track_file_count: 8,
track_count: 10,
total_track_count: 10,
size_on_disk: 1024,
size_on_disk: 317162578,
percent_of_tracks: 99.9,
}
}
@@ -282,11 +282,11 @@ pub mod test_utils {
pub fn lidarr_history_item() -> LidarrHistoryItem {
LidarrHistoryItem {
id: 1,
id: 13,
source_title: "Test source title".into(),
album_id: 1,
artist_id: 1,
track_id: 1,
artist_id: 3,
track_id: 5,
quality: quality_wrapper(),
date: DateTime::from(DateTime::parse_from_rfc3339("2023-01-01T00:00:00Z").unwrap()),
event_type: LidarrHistoryEventType::Grabbed,
@@ -459,7 +459,7 @@ pub mod test_utils {
pub fn track_file() -> TrackFile {
TrackFile {
id: 1,
id: 7,
path: "/music/P!nk/TRUSTFALL/01 - When I Get There.flac".to_string(),
size: 39216378,
quality: quality_wrapper(),
@@ -471,10 +471,10 @@ pub mod test_utils {
pub fn track() -> Track {
Track {
id: 1,
artist_id: 1,
id: 5,
artist_id: 3,
foreign_track_id: "test-foreign-track-id".to_string(),
track_file_id: 1,
track_file_id: 7,
album_id: 1,
explicit: false,
track_number: "1".to_string(),
@@ -91,7 +91,7 @@ mod tests {
"tmdbId": 1234,
"originalLanguage": {"id": 1, "name": "English"},
"sizeOnDisk": 3543348019i64,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
@@ -127,7 +127,7 @@ mod tests {
"tmdbId": 1234,
"originalLanguage": {"id": 1, "name": "English"},
"sizeOnDisk": 3543348019i64,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
@@ -230,7 +230,7 @@ mod tests {
"tmdbId": 1234,
"originalLanguage": {"id": 1, "name": "English"},
"sizeOnDisk": 3543348019i64,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
@@ -266,7 +266,7 @@ mod tests {
"tmdbId": 1234,
"originalLanguage": {"id": 1, "name": "English"},
"sizeOnDisk": 3543348019i64,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
+7 -6
View File
@@ -6,11 +6,12 @@ use crate::models::radarr_models::{
use crate::models::servarr_data::Notification;
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_models::DownloadStatus;
use crate::models::stateful_table::StatefulTable;
use crate::models::{Route, ScrollableText};
use crate::network::radarr_network::RadarrEvent;
use crate::network::{Network, RequestMethod};
use crate::utils::{convert_runtime, convert_to_gb};
use crate::utils::{convert_runtime, format_size};
use anyhow::Result;
use indoc::formatdoc;
use log::{debug, info, warn};
@@ -318,7 +319,7 @@ impl Network<'_, '_> {
..
} = movie_response;
let (hours, minutes) = convert_runtime(runtime);
let size = convert_to_gb(size_on_disk);
let size = format_size(size_on_disk, 2);
let studio = studio.clone().unwrap_or_default();
let quality_profile = app
.data
@@ -373,7 +374,7 @@ impl Network<'_, '_> {
IMDB: {imdb_rating}
Rotten Tomatoes: {rotten_tomatoes_rating}
Quality Profile: {quality_profile}
Size: {size:.2} GB
Size: {size}
Path: {path}
Studio: {studio}
Genres: {}",
@@ -392,7 +393,7 @@ impl Network<'_, '_> {
movie_details_modal.file_details = formatdoc!(
"Relative Path: {}
Absolute Path: {}
Size: {size:.2} GB
Size: {size}
Date Added: {}",
file.relative_path,
file.path,
@@ -664,11 +665,11 @@ pub(in crate::network::radarr_network::library) fn get_movie_status(
.iter()
.find(|&download| download.movie_id == movie_id)
{
if download.status == "downloading" {
if download.status == DownloadStatus::Downloading {
return "Downloading".to_owned();
}
if download.status == "completed" {
if download.status == DownloadStatus::Completed {
return "Awaiting Import".to_owned();
}
}
@@ -7,6 +7,7 @@ mod tests {
use crate::models::servarr_data::Notification;
use crate::models::servarr_data::radarr::modals::MovieDetailsModal;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::models::servarr_models::DownloadStatus;
use crate::models::stateful_table::SortOption;
use crate::network::NetworkResource;
use crate::network::network_tests::test_utils::{MockServarrApi, test_network};
@@ -700,7 +701,7 @@ mod tests {
"name": "English"
},
"sizeOnDisk": 0,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
@@ -758,7 +759,7 @@ mod tests {
IMDB:
Rotten Tomatoes:
Quality Profile: HD - 1080p
Size: 0.00 GB
Size: 0.00 MB
Path: /nfs/movies
Studio: 21st Century Alex
Genres: cool, family, fun"
@@ -1199,7 +1200,7 @@ mod tests {
false,
&[DownloadRecord {
movie_id: 1,
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
..DownloadRecord::default()
}],
1
@@ -1215,7 +1216,7 @@ mod tests {
false,
&[DownloadRecord {
movie_id: 1,
status: "completed".to_owned(),
status: DownloadStatus::Completed,
..DownloadRecord::default()
}],
1
@@ -3,11 +3,12 @@ pub mod test_utils {
use crate::models::radarr_models::{
AddMovieSearchResult, BlocklistItem, BlocklistItemMovie, Collection, CollectionMovie, Credit,
CreditType, DownloadRecord, DownloadsResponse, IndexerSettings, MediaInfo, MinimumAvailability,
Movie, MovieCollection, MovieFile, MovieHistoryItem, RadarrHistoryData, RadarrHistoryEventType,
RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating, RatingsList,
Movie, MovieCollection, MovieFile, MovieHistoryItem, MovieStatus, RadarrHistoryData,
RadarrHistoryEventType, RadarrHistoryItem, RadarrRelease, RadarrTask, RadarrTaskName, Rating,
RatingsList,
};
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
};
use crate::models::{HorizontallyScrollableText, ScrollableText};
use bimap::BiMap;
@@ -24,7 +25,7 @@ pub mod test_utils {
"name": "English"
},
"sizeOnDisk": 3543348019,
"status": "Downloaded",
"status": "released",
"overview": "Blah blah blah",
"path": "/nfs/movies",
"studio": "21st Century Alex",
@@ -233,7 +234,7 @@ pub mod test_utils {
title: "Test".to_owned().into(),
original_language: language(),
size_on_disk: 3543348019,
status: "Downloaded".to_owned(),
status: MovieStatus::Released,
overview: "Blah blah blah".to_owned(),
path: "/nfs/movies".to_owned(),
studio: Some("21st Century Alex".to_owned()),
@@ -299,7 +300,7 @@ pub mod test_utils {
tmdb_id: 1234,
title: HorizontallyScrollableText::from("Test"),
original_language: language(),
status: "released".to_owned(),
status: MovieStatus::Released,
overview: "New movie blah blah blah".to_owned(),
genres: genres(),
year: 2023,
@@ -339,7 +340,7 @@ pub mod test_utils {
pub fn download_record() -> DownloadRecord {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: "downloading".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
movie_id: 1,
size: 3543348019,
@@ -5,7 +5,7 @@ use serde_json::{Value, json};
use crate::models::Route;
use crate::models::readarr_models::{
AddAuthorBody, AddAuthorSearchResult, Author, DeleteParams, EditAuthorParams, ReadarrCommandBody,
ReadarrHistoryItem, ReadarrRelease,
ReadarrHistoryItem, ReadarrRelease, ReadarrReleaseResponse,
};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::models::stateful_table::StatefulTable;
@@ -235,10 +235,15 @@ impl Network<'_, '_> {
.await;
self
.handle_request::<(), Vec<ReadarrRelease>>(request_props, |release_vec, mut app| {
app.data.readarr_data.author_releases.set_items(release_vec);
.handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| {
app
.data
.readarr_data
.author_releases
.set_items(response.into_releases());
})
.await
.map(ReadarrReleaseResponse::into_releases)
}
pub(in crate::network::readarr_network) async fn get_readarr_author_history(
@@ -133,6 +133,48 @@ mod tests {
);
}
#[tokio::test]
async fn test_handle_get_author_releases_event_unwraps_the_release_envelope() {
let expected_releases = vec![torrent_release()];
let releases_json = json!({
"releases": [serde_json::from_str::<Value>(RELEASE_JSON).unwrap()],
"hiddenReleases": [],
"filterSummary": { "totalResults": 1, "displayedCount": 1 },
"siblingToggleEnabled": false
});
let (mock, app, _server) = MockServarrApi::get()
.returns(releases_json)
.query("authorId=3")
.build_for(ReadarrEvent::GetAuthorReleases(3))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
app
.data
.readarr_data
.author_releases
.set_items(vec![stale_release()]);
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetAuthorReleases(3))
.await;
mock.assert_async().await;
let ReadarrSerdeable::Releases(releases) = result.unwrap() else {
panic!("Expected Releases")
};
assert_eq!(releases, expected_releases);
assert_eq!(
app.lock().await.data.readarr_data.author_releases.items,
expected_releases
);
}
#[tokio::test]
async fn test_handle_get_author_releases_event_failure() {
let stale_releases = vec![stale_release()];
@@ -638,6 +680,7 @@ mod tests {
"qualityProfileId": 1,
"metadataProfileId": 1,
"tags": [1, 2],
"images": [],
"addOptions": {
"monitor": "all",
"monitorNewItems": "all",
@@ -680,6 +723,7 @@ mod tests {
"qualityProfileId": 1,
"metadataProfileId": 1,
"tags": [1, 2],
"images": [],
"addOptions": {
"monitor": "all",
"monitorNewItems": "all",
@@ -5,6 +5,7 @@ use serde_json::{Value, json};
use crate::models::Route;
use crate::models::readarr_models::{
Book, BookFile, DeleteParams, Edition, ReadarrCommandBody, ReadarrHistoryItem, ReadarrRelease,
ReadarrReleaseResponse,
};
use crate::models::servarr_data::readarr::readarr_data::ActiveReadarrBlock;
use crate::network::readarr_network::ReadarrEvent;
@@ -170,16 +171,19 @@ impl Network<'_, '_> {
.await;
self
.handle_request::<(), Vec<ReadarrRelease>>(request_props, |release_vec, mut app| {
.handle_request::<(), ReadarrReleaseResponse>(request_props, |response, mut app| {
let book_details_modal = app
.data
.readarr_data
.book_details_modal
.get_or_insert_default();
book_details_modal.book_releases.set_items(release_vec);
book_details_modal
.book_releases
.set_items(response.into_releases());
})
.await
.map(ReadarrReleaseResponse::into_releases)
}
pub(in crate::network::readarr_network) async fn get_books(
@@ -532,6 +532,57 @@ mod tests {
);
}
#[tokio::test]
async fn test_handle_get_book_releases_event_unwraps_the_release_envelope() {
let expected_releases = vec![torrent_release()];
let releases_json = json!({
"releases": [serde_json::from_str::<Value>(RELEASE_JSON).unwrap()],
"hiddenReleases": [],
"filterSummary": { "totalResults": 1, "displayedCount": 1 },
"siblingToggleEnabled": false
});
let (mock, app, _server) = MockServarrApi::get()
.returns(releases_json)
.query("bookId=7")
.build_for(ReadarrEvent::GetBookReleases(7))
.await;
{
let mut app = app.lock().await;
app.server_tabs.set_index(3);
let mut book_details_modal = BookDetailsModal::default();
book_details_modal
.book_releases
.set_items(vec![stale_release()]);
app.data.readarr_data.book_details_modal = Some(book_details_modal);
}
let mut network = test_network(&app);
let result = network
.handle_readarr_event(ReadarrEvent::GetBookReleases(7))
.await;
mock.assert_async().await;
let ReadarrSerdeable::Releases(releases) = result.unwrap() else {
panic!("Expected Releases")
};
assert_eq!(releases, expected_releases);
assert_eq!(
app
.lock()
.await
.data
.readarr_data
.book_details_modal
.as_ref()
.unwrap()
.book_releases
.items,
expected_releases
);
}
#[tokio::test]
async fn test_handle_get_book_releases_event_empty_book_details_modal() {
let expected_releases = vec![torrent_release()];
@@ -4,13 +4,12 @@ pub mod test_utils {
use crate::models::HorizontallyScrollableText;
use crate::models::readarr_models::{
AddAuthorBody, AddAuthorOptions, AddAuthorSearchResult, Author, AuthorStatus, BlocklistItem,
Book, BookFile, DownloadRecord, DownloadStatus, DownloadsResponse, Edition, MonitorType,
NewItemMonitorType, ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem,
ReadarrRelease,
Book, BookFile, DownloadRecord, DownloadsResponse, Edition, MonitorType, NewItemMonitorType,
ReadarrHistoryData, ReadarrHistoryEventType, ReadarrHistoryItem, ReadarrRelease,
};
use crate::models::servarr_data::modals::IndexerTestResultModalItem;
use crate::models::servarr_models::{
Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder,
DownloadStatus, Indexer, IndexerField, IndexerSettings, Quality, QualityWrapper, RootFolder,
};
use chrono::DateTime;
use serde_json::{Number, json};
@@ -395,6 +394,7 @@ pub mod test_utils {
quality_profile_id: 1,
metadata_profile_id: 1,
tags: Vec::default(),
images: Vec::new(),
tag_input_string: Some("usenet, testing".to_owned()),
add_options: AddAuthorOptions {
monitor: MonitorType::All,
@@ -41,8 +41,8 @@ mod tests {
"records": [{
"title": "Test Download Title",
"status": "downloading",
"id": 1,
"episodeId": 1,
"id": 9,
"episodeId": 11,
"size": 3543348019f64,
"sizeleft": 1771674009f64,
"outputPath": "/nfs/tv/Test show/season 1/",
@@ -1,14 +1,14 @@
use crate::models::servarr_data::sonarr::modals::SeasonDetailsModal;
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::servarr_models::Language;
use crate::models::servarr_models::{DownloadStatus, Language};
use crate::models::sonarr_models::{
DownloadRecord, DownloadStatus, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody,
DownloadRecord, Episode, EpisodeFile, MonitorEpisodeBody, SonarrCommandBody,
SonarrHistoryWrapper, SonarrRelease,
};
use crate::models::{Route, ScrollableText};
use crate::network::sonarr_network::SonarrEvent;
use crate::network::{Network, RequestMethod};
use crate::utils::convert_to_gb;
use crate::utils::format_size;
use anyhow::Result;
use indoc::formatdoc;
use log::info;
@@ -222,12 +222,12 @@ impl Network<'_, '_> {
overview.unwrap_or_default(),
));
if let Some(file) = episode_file {
let size = convert_to_gb(file.size);
let size = format_size(file.size, 2);
episode_details_modal.file_details = formatdoc!(
"
Relative Path: {}
Absolute Path: {}
Size: {size:.2} GB
Size: {size}
Language: {}
Date Added: {}",
file.relative_path,
@@ -2,8 +2,9 @@
mod tests {
use crate::models::servarr_data::sonarr::modals::{EpisodeDetailsModal, SeasonDetailsModal};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::servarr_models::DownloadStatus;
use crate::models::sonarr_models::{
DownloadRecord, DownloadStatus, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem,
DownloadRecord, Episode, MonitorEpisodeBody, Season, Series, SonarrHistoryItem,
SonarrHistoryWrapper, SonarrRelease, SonarrSerdeable,
};
use crate::models::stateful_table::SortOption;
@@ -69,7 +70,7 @@ mod tests {
..episode()
};
let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()];
let mut expected_sorted_episodes = vec![episode_1.clone(), episode_3.clone()];
let mut expected_sorted_episodes = vec![episode_3.clone(), episode_1.clone()];
let (mock, app, _server) = MockServarrApi::get()
.query("seriesId=1")
.returns(json!([episode_1, episode_2, episode_3]))
@@ -175,6 +176,7 @@ mod tests {
..episode()
};
let expected_episodes = vec![episode_1.clone(), episode_2.clone(), episode_3.clone()];
let expected_sorted_episodes = vec![episode_2.clone(), episode_3.clone(), episode_1.clone()];
let (mock, app, _server) = MockServarrApi::get()
.query("seriesId=1")
.returns(json!([episode_1, episode_2, episode_3]))
@@ -216,7 +218,7 @@ mod tests {
.unwrap()
.episodes
.items,
expected_episodes
expected_sorted_episodes
);
assert!(
app
@@ -294,10 +296,10 @@ mod tests {
"monitored": true
},
{
"id": 1,
"id": 11,
"seriesId": 1,
"tvdbId": 1234,
"episodeFileId": 1,
"episodeFileId": 7,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "Something cool",
@@ -1,13 +1,13 @@
#[cfg(test)]
pub mod test_utils {
use crate::models::servarr_models::{
Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
DownloadStatus, Indexer, IndexerField, Language, Quality, QualityWrapper, RootFolder,
};
use crate::models::sonarr_models::{
AddSeriesSearchResult, AddSeriesSearchResultStatistics, BlocklistItem, DownloadRecord,
DownloadStatus, DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season,
SeasonStatistics, Series, SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData,
SonarrHistoryEventType, SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName,
DownloadsResponse, Episode, EpisodeFile, MediaInfo, Rating, Season, SeasonStatistics, Series,
SeriesStatistics, SeriesStatus, SeriesType, SonarrHistoryData, SonarrHistoryEventType,
SonarrHistoryItem, SonarrRelease, SonarrTask, SonarrTaskName,
};
use crate::models::{HorizontallyScrollableText, ScrollableText};
use bimap::BiMap;
@@ -64,14 +64,14 @@ pub mod test_utils {
pub const EPISODE_JSON: &str = r#"{
"seriesId": 1,
"tvdbId": 1234,
"episodeFileId": 1,
"episodeFileId": 7,
"seasonNumber": 1,
"episodeNumber": 1,
"title": "Something cool",
"airDateUtc": "2024-02-10T07:28:45Z",
"overview": "Okay so this one time at band camp...",
"episodeFile": {
"id": 1,
"id": 7,
"relativePath": "/season 1/episode 1.mkv",
"path": "/nfs/tv/series/season 1/episode 1.mkv",
"size": 3543348019,
@@ -96,7 +96,7 @@ pub mod test_utils {
},
"hasFile": true,
"monitored": true,
"id": 1
"id": 11
}"#;
pub fn add_series_search_result() -> AddSeriesSearchResult {
@@ -139,8 +139,8 @@ pub mod test_utils {
DownloadRecord {
title: "Test Download Title".to_owned(),
status: DownloadStatus::Downloading,
id: 1,
episode_id: Some(Number::from(1i64)),
id: 9,
episode_id: Some(Number::from(11i64)),
size: 3543348019f64,
sizeleft: 1771674009f64,
output_path: Some(HorizontallyScrollableText::from(
@@ -159,10 +159,10 @@ pub mod test_utils {
pub fn episode() -> Episode {
Episode {
id: 1,
id: 11,
series_id: 1,
tvdb_id: 1234,
episode_file_id: 1,
episode_file_id: 7,
season_number: 1,
episode_number: 1,
title: "Something cool".to_owned(),
@@ -178,7 +178,7 @@ pub mod test_utils {
pub fn episode_file() -> EpisodeFile {
EpisodeFile {
id: 1,
id: 7,
relative_path: "/season 1/episode 1.mkv".to_owned(),
path: "/nfs/tv/series/season 1/episode 1.mkv".to_owned(),
size: 3543348019,
+3 -3
View File
@@ -12,7 +12,7 @@ use crate::ui::utils::{get_width_from_percentage, layout_block_top_border};
use crate::ui::widgets::confirmation_prompt::ConfirmationPrompt;
use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::popup::{Popup, Size};
use crate::utils::convert_f64_to_gb;
use crate::utils::format_size;
#[cfg(test)]
#[path = "downloads_ui_tests.rs"]
@@ -97,12 +97,12 @@ fn draw_downloads(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
} else {
1f64 - (*sizeleft / *size)
};
let file_size: f64 = convert_f64_to_gb(*size);
let file_size = format_size(*size as i64, 2);
Row::new(vec![
Cell::from(title.to_owned()),
Cell::from(format!("{:.0}%", percent * 100.0)),
Cell::from(format!("{file_size:.2} GB")),
Cell::from(file_size),
Cell::from(
output_path
.as_ref()
+35 -9
View File
@@ -1,7 +1,8 @@
use crate::app::App;
use crate::models::Route;
use crate::models::lidarr_models::{LidarrHistoryItem, LidarrRelease, Track};
use crate::models::lidarr_models::{DownloadRecord, LidarrHistoryItem, LidarrRelease, Track};
use crate::models::servarr_data::lidarr::lidarr_data::{ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock};
use crate::models::servarr_models::DownloadStatus;
use crate::ui::lidarr_ui::library::track_details_ui::TrackDetailsUi;
use crate::ui::lidarr_ui::lidarr_ui_utils::create_history_event_details;
use crate::ui::styles::{ManagarrStyle, secondary_style};
@@ -14,7 +15,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::message::Message;
use crate::ui::widgets::popup::{Popup, Size};
use crate::ui::{DrawUi, draw_popup, draw_tabs};
use crate::utils::convert_to_gb;
use crate::utils::format_size;
use ratatui::Frame;
use ratatui::layout::{Alignment, Constraint, Rect};
use ratatui::prelude::{Line, Stylize, Text};
@@ -150,6 +151,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.expect("Album details modal is unpopulated")
.tracks,
);
let downloads_vec = &app.data.lidarr_data.downloads.items;
let track_row_mapping = |track: &Track| {
let Track {
@@ -187,6 +189,7 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let duration_str = format!("{mins}:{secs:02}");
decorate_track_row_with_style(
downloads_vec,
track,
Row::new(vec![
Cell::from(track_number.clone()),
@@ -221,12 +224,35 @@ fn draw_tracks_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
}
}
fn decorate_track_row_with_style<'a>(track: &Track, row: Row<'a>) -> Row<'a> {
if track.has_file {
row.downloaded()
} else {
row.missing()
fn decorate_track_row_with_style<'a>(
downloads_vec: &[DownloadRecord],
track: &Track,
row: Row<'a>,
) -> Row<'a> {
if !track.has_file {
let default_album_id = Number::from(-1i64);
if let Some(download) = downloads_vec.iter().find(|&download| {
download
.album_id
.as_ref()
.unwrap_or(&default_album_id)
.as_i64()
.unwrap()
== track.album_id
}) {
if download.status == DownloadStatus::Downloading {
return row.downloading();
}
if download.status == DownloadStatus::Completed {
return row.awaiting_import();
}
}
return row.missing();
}
row.downloaded()
}
fn draw_album_history_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
@@ -351,7 +377,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
&& active_lidarr_block != ActiveLidarrBlock::ManualAlbumSearchConfirmPrompt,
app.should_text_scroll,
);
let size = convert_to_gb(*size);
let size = format_size(*size, 1);
let rejected_str = if *rejected { "⛔" } else { "" };
let peers = if seeders.is_none() || leechers.is_none() {
Text::from("")
@@ -382,7 +408,7 @@ fn draw_album_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(rejected_str),
Cell::from(title.to_string()),
Cell::from(indexer.clone()),
Cell::from(format!("{size:.1} GB")),
Cell::from(size),
Cell::from(peers),
Cell::from(quality_name),
])
@@ -2,14 +2,17 @@
mod tests {
use pretty_assertions::assert_eq;
use ratatui::widgets::{Cell, Row};
use serde_json::Number;
use strum::IntoEnumIterator;
use crate::app::App;
use crate::models::lidarr_models::Track;
use crate::models::lidarr_models::{DownloadRecord, Track};
use crate::models::servarr_data::lidarr::lidarr_data::{
ALBUM_DETAILS_BLOCKS, ActiveLidarrBlock, TRACK_DETAILS_BLOCKS,
};
use crate::models::servarr_models::DownloadStatus;
use crate::models::stateful_table::StatefulTable;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::download_record;
use crate::ui::DrawUi;
use crate::ui::lidarr_ui::library::album_details_ui::{
AlbumDetailsUi, decorate_track_row_with_style,
@@ -39,7 +42,7 @@ mod tests {
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&track, row.clone());
let style = decorate_track_row_with_style(&[], &track, row.clone());
assert_eq!(style, row.downloaded());
}
@@ -52,11 +55,163 @@ mod tests {
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&track, row.clone());
let style = decorate_track_row_with_style(&[], &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_downloading_when_track_has_no_file_and_album_is_downloading()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.downloading());
}
#[test]
fn test_decorate_track_row_with_style_awaiting_import_when_track_has_no_file_and_album_download_is_completed()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Completed)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.awaiting_import());
}
#[test]
fn test_decorate_track_row_with_style_missing_when_album_download_is_neither_downloading_nor_completed()
{
let downloads_vec = vec![album_download(7, DownloadStatus::Queued)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_missing_when_only_a_different_album_is_downloading() {
let downloads_vec = vec![album_download(8, DownloadStatus::Downloading)];
let track = Track {
has_file: false,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.missing());
}
#[test]
fn test_decorate_track_row_with_style_downloaded_when_track_has_file_and_album_is_downloading() {
let downloads_vec = vec![album_download(7, DownloadStatus::Downloading)];
let track = Track {
has_file: true,
album_id: 7,
..Track::default()
};
let row = Row::new(vec![Cell::from("test".to_owned())]);
let style = decorate_track_row_with_style(&downloads_vec, &track, row.clone());
assert_eq!(style, row.downloaded());
}
mod test_track_row_styling {
use pretty_assertions::assert_eq;
use ratatui::style::Style;
use crate::network::lidarr_network::lidarr_network_test_utils::test_utils::track;
use crate::ui::styles::{downloading_style, missing_style};
use crate::ui::ui_test_utils::test_utils::{TerminalSize, create_test_terminal};
use super::*;
#[test]
fn test_album_details_ui_renders_downloading_track_with_downloading_style() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into());
set_tracks_with_unselected_probe(&mut app);
let style = rendered_row_style(&mut app, "Unselected track");
assert_eq!(style.fg, downloading_style().fg);
}
#[test]
fn test_album_details_ui_renders_missing_track_with_missing_style_when_queue_is_empty() {
let mut app = App::test_default_fully_populated();
app.push_navigation_stack(ActiveLidarrBlock::AlbumDetails.into());
app.data.lidarr_data.downloads.set_items(vec![]);
set_tracks_with_unselected_probe(&mut app);
let style = rendered_row_style(&mut app, "Unselected track");
assert_eq!(style.fg, missing_style().fg);
}
fn set_tracks_with_unselected_probe(app: &mut App<'_>) {
let album_details_modal = app.data.lidarr_data.album_details_modal.as_mut().unwrap();
album_details_modal.album_details_tabs.set_index(0);
album_details_modal.tracks.set_items(vec![
track(),
Track {
has_file: false,
title: "Unselected track".to_owned(),
..track()
},
]);
}
fn rendered_row_style(app: &mut App<'_>, needle: &str) -> Style {
let (width, height) = TerminalSize::Large.to_cartesian();
let mut terminal = create_test_terminal(width, height);
terminal
.draw(|f| {
AlbumDetailsUi::draw(f, app, f.area());
})
.unwrap();
let buffer = terminal.backend().buffer();
for y in 0..height {
let row = (0..width)
.map(|x| buffer.cell((x, y)).expect("a rendered cell").symbol())
.collect::<String>();
if let Some(byte_index) = row.find(needle) {
let column = row[..byte_index].chars().count() as u16;
return buffer.cell((column, y)).expect("a rendered cell").style();
}
}
panic!("no rendered row contained {needle}");
}
}
mod snapshot_tests {
use crate::ui::ui_test_utils::test_utils::TerminalSize;
use rstest::rstest;
@@ -177,4 +332,13 @@ mod tests {
insta::assert_snapshot!(output);
}
}
fn album_download(album_id: i64, status: DownloadStatus) -> DownloadRecord {
DownloadRecord {
status,
album_id: Some(Number::from(album_id)),
artist_id: Some(Number::from(3i64)),
..download_record()
}
}
}
@@ -26,7 +26,7 @@ use crate::ui::widgets::managarr_table::ManagarrTable;
use crate::ui::widgets::message::Message;
use crate::ui::widgets::popup::{Popup, Size};
use crate::ui::{DrawUi, draw_popup, draw_tabs};
use crate::utils::convert_to_gb;
use crate::utils::format_size;
use ratatui::layout::Alignment;
use ratatui::text::Text;
use serde_json::Number;
@@ -223,7 +223,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
];
if let Some(stats) = current_selection.statistics.as_ref() {
let size = convert_to_gb(stats.size_on_disk);
let size = format_size(stats.size_on_disk, 2);
artist_description.extend(vec![
Line::from(vec![
"Albums: ".primary().bold(),
@@ -235,7 +235,7 @@ fn draw_artist_description(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
]),
Line::from(vec![
"Size on Disk: ".primary().bold(),
format!("{size:.2} GB").default_color(),
size.default_color(),
]),
]);
}
@@ -279,13 +279,13 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
.release_date
.map_or_else(|| "N/A".to_owned(), |d| d.format("%Y-%m-%d").to_string());
let track_count = album.statistics.as_ref().map_or_else(
|| "0/0".to_owned(),
|| "N/A".to_owned(),
|s| format!("{}/{}", s.track_file_count, s.total_track_count),
);
let size = album
.statistics
.as_ref()
.map_or(0f64, |s| convert_to_gb(s.size_on_disk));
.map_or_else(|| "N/A".to_owned(), |s| format_size(s.size_on_disk, 2));
let duration_mins = album.duration / 60000;
decorate_album_row_with_style(
@@ -297,7 +297,7 @@ fn draw_albums_table(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(track_count),
Cell::from(format!("{duration_mins} min")),
Cell::from(release_date),
Cell::from(format!("{size:.2} GB")),
Cell::from(size),
]),
)
};
@@ -497,7 +497,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
&& active_lidarr_block != ActiveLidarrBlock::ManualArtistSearchConfirmPrompt,
app.should_text_scroll,
);
let size = convert_to_gb(*size);
let size = format_size(*size, 1);
let rejected_str = if *rejected { "⛔" } else { "" };
let peers = if seeders.is_none() || leechers.is_none() {
Text::from("")
@@ -528,7 +528,7 @@ fn draw_artist_releases(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(rejected_str),
Cell::from(title.to_string()),
Cell::from(indexer.clone()),
Cell::from(format!("{size:.1} GB")),
Cell::from(size),
Cell::from(peers),
Cell::from(quality_name),
])
@@ -249,6 +249,19 @@ mod tests {
);
}
#[test]
fn test_artist_details_ui_renders_album_without_statistics() {
let mut app = App::test_default_fully_populated();
app.data.lidarr_data.albums.items[0].statistics = None;
app.push_navigation_stack(ActiveLidarrBlock::ArtistDetails.into());
let output = render_to_string_with_app(TerminalSize::Large, &mut app, |f, app| {
ArtistDetailsUi::draw(f, app, f.area());
});
insta::assert_snapshot!(output);
}
#[test]
fn test_artist_details_ui_renders_delete_album_prompt_over_artist_details() {
let mut app = App::test_default_fully_populated();
+3 -3
View File
@@ -13,7 +13,7 @@ use crate::ui::widgets::{
confirmation_prompt::ConfirmationPrompt,
popup::{Popup, Size},
};
use crate::utils::convert_to_gb;
use crate::utils::format_size;
use crate::{
app::App,
models::{
@@ -104,7 +104,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
let size = artist
.statistics
.as_ref()
.map_or(0f64, |stats| convert_to_gb(stats.size_on_disk));
.map_or(0, |stats| stats.size_on_disk);
let quality_profile = quality_profile_map
.get_by_left(&artist.quality_profile_id)
.cloned()
@@ -140,7 +140,7 @@ fn draw_library(f: &mut Frame<'_>, app: &mut App<'_>, area: Rect) {
Cell::from(metadata_profile),
Cell::from(albums.to_string()),
Cell::from(tracks),
Cell::from(format!("{size:.2} GB")),
Cell::from(format_size(size, 2)),
Cell::from(monitored.to_owned()),
Cell::from(tags),
]),
@@ -10,8 +10,8 @@ expression: output
│ Tracks │ History │ Manual Search │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Source ▼ Age ⛔ Title Indexer Size Peers Quality │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │
│ │
│ │
│ │
@@ -10,8 +10,8 @@ expression: output
│ Tracks │ History │ Manual Search │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Source ▼ Age ⛔ Title Indexer Size Peers Quality │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │
│ │
│ │
│ │
@@ -10,8 +10,8 @@ expression: output
│ Tracks │ History │ Manual Search │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Source Age ⛔ Title Indexer Size Peers Quality │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │
│ │
│ │
│ │
@@ -10,8 +10,8 @@ expression: output
│ Tracks │ History │ Manual Search │
│───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────│
│ Source ▼ Age ⛔ Title Indexer Size Peers Quality │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless │
│=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless │
│ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless │
│ │
│ │
│ │
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││
││ ││
││ ││
││ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -24,8 +24,8 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││
││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││
││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -24,8 +24,8 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││────────────────────────────────────────│Rejection reasons: │───────────────────────────────────────││
││ Source ▼ Age ⛔ Title │• Unknown quality profile │e Peers Quality ││
││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ GB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Relea│ │ GB Lossless ││
││=> torrent 1 days ⛔ Test Relea│• Release is already mapped │ MB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Relea│ │ MB Lossless ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -24,8 +24,8 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Source Age ⛔ Title Indexer Size Peers Quality ││
││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││
││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││
││ ││
││ ││
││ ││
@@ -24,8 +24,8 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Source ▼ Age ⛔ Title Indexer Size Peers Quality ││
││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 GB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 GB Lossless ││
││=> torrent 1 days ⛔ Test Release kickass torrents 0.0 MB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Release DrunkenSlug 0.0 MB Lossless ││
││ ││
││ ││
││ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││
││ ││
││ ││
││ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││
││ ││
││ ││
││ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -24,8 +24,8 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Source ▼ Age ⛔ Title │ │ Size Peers Quality ││
││=> torrent 1 days ⛔ Test Releas│ │ 0.0 GB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Releas│ │ 0.0 GB Lossless ││
││=> torrent 1 days ⛔ Test Releas│ │ 0.0 MB 2 / 1 Lossless ││
││ usenet 1 days ⛔ Test Releas│ │ 0.0 MB Lossless ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -0,0 +1,52 @@
---
source: src/ui/lidarr_ui/library/artist_details_ui_tests.rs
expression: output
---
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
│seven years to finish. │
│Disambiguation: American pianist │
│Type: Person │
│Status: Continuing │
│Genres: soundtrack │
│Rating: 84% │
│Path: /nfs/music/test-artist │
│Quality Profile: Lossless │
│Metadata Profile: Standard │
│Monitored: Yes │
│Albums: 1 │
│Tracks: 12/15 │
│╭ Artist Details ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮│
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album N/A 0 min 2023-01-01 N/A ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
││ ││
│╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯│
╰───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ ╭───╮ │ Release Date Size ││
││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 0.00 GB ││
││=> 🏷 Test Album │ Delete Album Files: │ ✔ │ │ 2023-01-01 302.47 MB ││
││ │ ╰───╯ │ ││
││ │ ╭───╮ │ ││
││ │ Add List Exclusion: │ │ │ ││
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search │ │ ││
││─────────────────────────────────────────│ │───────────────────────────────────────────││
││ Monitored Title │ │ Release Date Size ││
││=> 🏷 Test Album │ │ 2023-01-01 0.00 GB ││
││=> 🏷 Test Album │ │ 2023-01-01 302.47 MB ││
││ │ │ ││
││ │ │ ││
││ │ │ ││
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Monitor New Albums: │All Albums ▼ │ │ ││
││──────────────────│ ╰─────────────────────────────────────────────────╯ │────────────────────││
││ Monitored Titl│ ╭─────────────────────────────────────────────────╮ │ize ││
││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │.00 GB ││
││=> 🏷 Test│ Quality Profile: │Lossless ▼ │ │02.47 MB ││
││ │ ╰─────────────────────────────────────────────────╯ │ ││
││ │ ╭─────────────────────────────────────────────────╮ │ ││
││ │ Metadata Profile: │Standard ▼ │ │ ││
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
╭ Alex ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
│Artist: Alex │
│Overview: some interesting description of the artist She was born in Madison, Wisconsin: a city she has never really left. Her first album took her│
@@ -24,7 +24,7 @@ expression: output
││ Albums │ History │ Manual Search ││
││─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────││
││ Monitored Title Type Tracks Duration Release Date Size ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 0.00 GB ││
││=> 🏷 Test Album Album 8/10 0 min 2023-01-01 302.47 MB ││
││ ││
││ ││
││ ││
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
@@ -4,4 +4,4 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
@@ -4,7 +4,7 @@ expression: output
---
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Name ▼ Type Status Quality Profile Metadata Profile Albums Tracks Size Monitored Tags
=> Alex Person Continuing Lossless Standard 1 12/15 0.00 GB 🏷 alex
=> Alex Person Continuing Lossless Standard 1 12/15 0.01 MB 🏷 alex
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more