diff --git a/.gitignore b/.gitignore index 43ec942..d6769d4 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ venv temp .notebooks **/old/ -logs/* \ No newline at end of file +logs/* +.idea/* diff --git a/.vscode/settings.json b/.vscode/settings.json index 9f30128..cd34d5e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,5 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true, + "python.testing.pytestEnabled": true } \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 378bfcd..d9c1059 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,9 +2,9 @@ ## Table of contents - [Overview](#overview) -- [Feature Requests](#feature--requests) -- [Bug Reports](#bug--reports) -- [Code Contributions](#code--contributions) +- [Feature Requests](#feature-requests) +- [Bug Reports](#bug-reports) +- [Code Contributions](#code-contributions) ## Overview Thank you for wanting to contribute to this project. @@ -27,7 +27,7 @@ Please go follow these steps to submit a bug: - Create meaningful logs by: 1) Switch decluttarr to debug mode (setting LOG_LEVEL: DEBUG) 2) Turn off all remove functions but one where you expect a removal (example: REMOVE_STALLED: True and the rest on False) -3) Let it run until the supposed remove should be trigged +3) Let it run until the supposed remove should be triggered 4) Paste the full logs to a pastebin 5) Share your settings (docker-compose or config.conf) 6) Optional: If helpful, share screenshots showing the problem (from your arr-app or qbit) @@ -40,23 +40,23 @@ Code contributions are very welcome - thanks for helping improve this app! 3) Only commit code that you have written yourself and is not owned by anybody else 4) Create a PR against the "dev" branch 5) Be responsive to code review -5) Once the code is reviewed and OK, it will be merged to dev branch, which will create the "dev"-docker image -6) Help testing that the dev image works -7= Finally, we will then commit the change to the main branch, which will create the "latest"-docker image +6) Once the code is reviewed and OK, it will be merged to dev branch, which will create the "dev"-docker image +7) Help testing that the dev image works +8) Finally, we will then commit the change to the main branch, which will create the "latest"-docker image You do not need to know about how to create docker images to contribute here. To get started: 1) Create a fork of decluttarr 2) Clone the git repository from the dev branch to your local machine `git clone -b dev https://github.com/yourName/decluttarr` -2) Create a virtual python environment (`python3 -m venv venv`) -3) Activate the virtual environment (`source venv/bin/activate`) -4) Install python libraries (`pip install -r docker/requirements.txt`) -5) Adjust the config/config.conf to your needs -6) Adjust the code in the files as needed -7) Run the script (`python3 main.py`) -8) Push your changes to your own git repo and use a descriptive name for the branch name (e.g. add-feature-to-xyz; bugfix-xyz) -9) Test the dev-image it creates automatically -10) Create the PR from your repo to ManiMatter/decluttarr (dev branch) -11) Make sure all checks pass -12) Squash your commits -13) Test that the docker image works that was created when you pushed to your fork +3) Create a virtual python environment (`python3 -m venv venv`) +4) Activate the virtual environment (`source venv/bin/activate`) +5) Install python libraries (`pip install -r docker/requirements.txt`) +6) Adjust the config/config.conf to your needs +7) Adjust the code in the files as needed +8) Run the script (`python3 main.py`) +9) Push your changes to your own git repo and use a descriptive name for the branch name (e.g. add-feature-to-xyz; bugfix-xyz) +10) Test the dev-image it creates automatically +11) Create the PR from your repo to ManiMatter/decluttarr (dev branch) +12) Make sure all checks pass +13) Squash your commits +14) Test that the docker image works that was created when you pushed to your fork diff --git a/README.md b/README.md index c3e75bb..43f19a1 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ _Like this app? Thanks for giving it a_ ⭐️ - [Docker-compose with config file (recommended)](#docker-docker-compose-together-with-configyaml) - [Docker-compose only](#docker-specifying-all-settings-in-docker-compose) - [Explanation of the settings](#explanation-of-the-settings) - - [General](#general) + - [General](#general-settings) - [LOG_LEVEL](#log_level) - [TEST_RUN](#test_run) - [TIMER](#timer) @@ -36,7 +36,7 @@ _Like this app? Thanks for giving it a_ ⭐️ - [REMOVE_UNMONITORED](#remove_unmonitored) - [SEARCH_CUTOFF_UNMET_CONTENT](#search_unmet_cutoff_content) - [SEARCH_MISSING_CONTENT](#search_missing_content) - - [Instances](#instances) + - [Instances](#arr-instances) - [SONARR](#sonarr) - [RADARR](#radarr) - [READARR](#readarr) @@ -64,7 +64,7 @@ Feature overview: - Removing downloads that are stalled (remove_stalled) - Removing downloads belonging to movies/series/albums etc. that have been marked as "unmonitored" (remove_unmonitored) - Periodically searching for better content on movies/series/albums etc. where cutoff has not been reached yet (search_cutoff_unmet_content) -- Periodcially searching for missing content that has not yet been found (search_missing_content) +- Periodically searching for missing content that has not yet been found (search_missing_content) Key behaviors: @@ -91,7 +91,7 @@ How to run this: - The feature that distinguishes private and private trackers (private_tracker_handling, public_tracker_handling) does not work - Removal of bad files and <100% availability (remove_bad_files) does not work - If you see strange errors such as "found 10 / 3 times", consider turning on the setting "Reject Blocklisted Torrent Hashes While Grabbing". On nightly Radarr/Sonarr/Readarr/Lidarr/Whisparr, the option is located under settings/indexers in the advanced options of each indexer, on Prowlarr it is under settings/apps and then the advanced settings of the respective app -- If you use qBittorrent and none of your torrents get removed and the verbose logs tell that all torrents are protected by the protected_tag even if they are not, you may be using a qBittorrent version that has problems with API calls and you may want to consider switching to a different qBit image (see https://github.com/ManiMatter/decluttarr/issues/56) +- If you use qBittorrent and none of your torrents get removed and the verbose logs tell that all torrents are protected by the protected_tag even if they are not, you may be using a qBittorrent version that has problems with API calls, and you may want to consider switching to a different qBit image (see https://github.com/ManiMatter/decluttarr/issues/56) - Currently, “\*Arr” apps are only supported in English. Refer to issue https://github.com/ManiMatter/decluttarr/issues/132 for more details - If you experience yaml issues, please check the closed issues. There are different notations, and it may very well be that the issue you found has already been solved in one of the issues. Once you figured your problem, feel free to post your yaml to help others here: https://github.com/ManiMatter/decluttarr/issues/173 @@ -125,13 +125,13 @@ jobs: ### Running in docker In docker, there are two ways how you can run decluttarr. -The [recommendeded approach](#docker-docker-compose-together-with-configyaml) is to use a config.yaml file (similar to running the script [locally](#running-locally)). +The [recommended approach](#docker-docker-compose-together-with-configyaml) is to use a config.yaml file (similar to running the script [locally](#running-locally)). Alternatively, you can put all settings [directly in your docker-compose](#docker-specifying-all-settings-in-docker-compose), which may bloat it a bit. #### Docker: Docker-compose together with Config.yaml 1. Use the following input for your `docker-compose.yml` -2. Download the config_example.yaml from the config folder (on github) and put it into your mounted folder +2. Download the config_example.yaml from the config folder (on GitHub) and put it into your mounted folder 3. Rename it to config.yaml and adjust the settings to your needs 4. Run `docker-compose up -d` in the directory where the file is located to create the docker container @@ -162,7 +162,7 @@ If you want to have everything in docker compose: 1. Use the following input for your `docker-compose.yml` 2. Tweak the settings to your needs 3. Remove the things that are commented out (if you don't need them), or uncomment them -4. If you face problems with yaml formats etc, please first check the open and closed issues on github, before opening new ones +4. If you face problems with yaml formats etc., please first check the open and closed issues on GitHub, before opening new ones 5. Run `docker-compose up -d` in the directory where the file is located to create the docker container Note: Always pull the "**latest**" version. The "dev" version is for testing only, and should only be pulled when contributing code or supporting with bug fixes @@ -207,7 +207,7 @@ services: SEARCH_MISSING_CONTENT: True # # --- OR: Jobs (with job-specific settings) --- - # Alternatively, you can use the below notation, which for certain jobs allows you to set additioanl parameters + # Alternatively, you can use the below notation, which for certain jobs allows you to set additional parameters # As written above, these can also be set as Job Defaults so you don't have to specify them as granular as below. # REMOVE_BAD_FILES: | # keep_archives: True @@ -314,14 +314,14 @@ Configures the general behavior of the application (across all features) - Allows you to configure download client names that will be skipped by decluttarr Note: The names provided here have to 100% match with how you have named your download clients in your *arr application(s) - Type: List of strings -- Is Mandatory: No (Defaults to [], ie. nothing ignored]) +- Is Mandatory: No (Defaults to [], i.e. nothing ignored]) #### PRIVATE_TRACKER_HANDLING / PUBLIC_TRACKER_HANDLING - Defines what happens with private/public tracker torrents if they are flagged by a removal job - Note that this only works for qbittorrent currently (if you set up qbittorrent in your config) - "remove" means that torrents are removed (default behavior) - - "skip" means they are disregarded (which some users might find handy to protect their private trackers prematurely, ie., before their seed targets are met) + - "skip" means they are disregarded (which some users might find handy to protect their private trackers prematurely, i.e., before their seed targets are met) - "obsolete_tag" means that rather than being removed, the torrents are tagged. This allows other applications (such as [qbit_manage](https://github.com/StuffAnThings/qbit_manage) to monitor them and remove them once seed targets are fulfilled) - Type: String - Permissible Values: remove, skip, obsolete_tag @@ -372,10 +372,10 @@ If a job has the same settings configured on job-level, the job-level settings w #### MAX_CONCURRENT_SEARCHES - Only relevant together with search_unmet_cutoff_content and search_missing_content -- Specified how many ites concurrently on a single arr should be search for in a given iteration +- Specified how many ites concurrently on a single arr should be searched for in a given iteration - Each arr counts separately - Example: If your wanted-list has 100 entries, and you define "3" as your number, after roughly 30 searches you'll have all items on your list searched for. -- Since the timer-setting steer how often the jobs run, if you put 10minutes there, after one hour you'll have run 6x, and thus already processed 18 searches. Long story short: No need to put a very high number here (else you'll just create unecessary traffic on your end..). +- Since the timer-setting steer how often the jobs run, if you put 10minutes there, after one hour you'll have run 6x, and thus already processed 18 searches. Long story short: No need to put a very high number here (else you'll just create unnecessary traffic on your end.). - Type: Integer - Permissible Values: Any number - Is Mandatory: No (Defaults to 3) @@ -389,12 +389,12 @@ This is the interesting section. It defines which job you want decluttarr to run - Steers whether files within torrents are marked as 'not download' if they match one of these conditions 1) They are less than 100% available 2) They are not one of the desired file types supported by the *arr apps: - 3) They contain one of these words (case insensitive) and are smaller than 500 MB: + 3) They contain one of these words (case-insensitive) and are smaller than 500 MB: - Trailer - Sample - If all files of a torrent are marked as 'not download' then the torrent will be removed and blacklisted -- Note that this is only supported when qBittorrent is configured in decluttarr and it will turn on the setting 'Keep unselected files in ".unwanted" folder' in qBittorrent +- Note that this is only supported when qBittorrent is configured in decluttarr, and it will turn on the setting 'Keep unselected files in ".unwanted" folder' in qBittorrent - Type: Boolean or Dict - Permissible Values: True, False or keep_archives (bool) - Is Mandatory: No (Defaults to False) @@ -431,8 +431,8 @@ This is the interesting section. It defines which job you want decluttarr to run - Permissible Values: True, False or max_strikes (int) - Is Mandatory: No (Defaults to False) - Note: - - With max_strikes you can define how many time this torrent can be caught before being removed - - Instead of configuring it here, you may also configure it as a default across all jobs or use the built-in defaults (see futher above under "Max_Strikes") + - With max_strikes you can define how many times this torrent can be caught before being removed + - Instead of configuring it here, you may also configure it as a default across all jobs or use the built-in defaults (see further above under "Max_Strikes") #### REMOVE_MISSING_FILES @@ -455,7 +455,7 @@ This is the interesting section. It defines which job you want decluttarr to run - Steers whether slow downloads are removed from the queue - Blocklisted: Yes -- Note: Does not apply to usenet downloads (since there users pay for certain speed, slowness should not occurr) +- Note: Does not apply to usenet downloads (since there users pay for certain speed, slowness should not occur) - Type: Boolean or Dict - Permissible Values: If bool: True, False diff --git a/config/config_example.yaml b/config/config_example.yaml index 0eb28f6..3b3eb8e 100644 --- a/config/config_example.yaml +++ b/config/config_example.yaml @@ -58,7 +58,7 @@ instances: download_clients: qbittorrent: - - base_url: "http://qbittorrent:8080" # You can use decluttar without qbit (not all features available, see readme). + - base_url: "http://qbittorrent:8080" # You can use decluttarr without qbit (not all features available, see readme). # username: xxxx # (optional -> if not provided, assuming not needed) # password: xxxx # (optional -> if not provided, assuming not needed) # name: "qBittorrent" # (optional -> if not provided, assuming "qBittorrent". Must correspond with what is specified in your *arr as download client name) diff --git a/docker/dockerfile b/docker/dockerfile index 68baac9..fb28d61 100644 --- a/docker/dockerfile +++ b/docker/dockerfile @@ -37,7 +37,7 @@ COPY main.py main.py COPY src src -# Install health check +# Install health check RUN apt-get update && apt-get install -y --no-install-recommends procps && \ apt-get clean && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ diff --git a/docker/requirements.txt b/docker/requirements.txt index bb85b9c..d445171 100644 --- a/docker/requirements.txt +++ b/docker/requirements.txt @@ -11,3 +11,4 @@ autoflake==2.3.1 isort==5.13.2 envyaml==1.10.211231 demjson3==3.0.6 +ruff==0.11.11 \ No newline at end of file diff --git a/main.py b/main.py index c488d79..7a30e08 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ import asyncio -from src.settings.settings import Settings -from src.utils.startup import launch_steps -from src.utils.log_setup import logger from src.job_manager import JobManager +from src.settings.settings import Settings +from src.utils.log_setup import logger +from src.utils.startup import launch_steps settings = Settings() job_manager = JobManager(settings) diff --git a/pyproject.toml b/pyproject.toml index e407960..c9465bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.pylint] -ignore = ".venv" +ignore = [".venv", "old"] ignore-patterns = ["__pycache__", ".pytest_cache"] disable = [ "logging-fstring-interpolation", # W1203 @@ -9,6 +9,7 @@ disable = [ "missing-class-docstring", # C0115 "missing-function-docstring", # C0116 "line-too-long", # C0301 + "too-few-public-methods", # R0903 ] [tool.pytest.ini_options] diff --git a/ruff.toml b/ruff.toml new file mode 100644 index 0000000..d59403a --- /dev/null +++ b/ruff.toml @@ -0,0 +1,54 @@ +# Exclude a variety of commonly ignored directories. +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", +] + +# Assume Python 3.10 +target-version = "py310" + +[lint] +# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default. +# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or +# McCabe complexity (`C901`) by default. +select = ["ALL"] +ignore = ["D203", "D212", "E501"] + +[lint.per-file-ignores] +# "src/jobs/remove_bad_files.py" = ["ERA001"] +"tests/settings/test__user_config_from_env.py" = ["S101"] +"tests/jobs/test_strikes_handler.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_unmonitored.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_stalled.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_slow.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_orphans.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_missing_files.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_metadata_missing.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_failed_imports.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_failed_downloads.py" = ["S101", "SLF001"] +"tests/jobs/test_remove_bad_files.py" = ["S101", "SLF001"] +"tests/jobs/test_removal_handler.py" = ["S101", "SLF001"] diff --git a/src/job_manager.py b/src/job_manager.py index 5b5cc40..2ff3b16 100644 --- a/src/job_manager.py +++ b/src/job_manager.py @@ -1,18 +1,16 @@ # Cleans the download queue -from src.utils.log_setup import logger -from src.utils.queue_manager import QueueManager - from src.jobs.remove_bad_files import RemoveBadFiles -from src.jobs.remove_failed_imports import RemoveFailedImports from src.jobs.remove_failed_downloads import RemoveFailedDownloads +from src.jobs.remove_failed_imports import RemoveFailedImports from src.jobs.remove_metadata_missing import RemoveMetadataMissing from src.jobs.remove_missing_files import RemoveMissingFiles from src.jobs.remove_orphans import RemoveOrphans from src.jobs.remove_slow import RemoveSlow from src.jobs.remove_stalled import RemoveStalled from src.jobs.remove_unmonitored import RemoveUnmonitored - from src.jobs.search_handler import SearchHandler +from src.utils.log_setup import logger +from src.utils.queue_manager import QueueManager class JobManager: @@ -27,7 +25,7 @@ class JobManager: await self.search_jobs() async def removal_jobs(self): - logger.verbose(f"") + logger.verbose("") logger.verbose(f"Cleaning queue on {self.arr.name}:") if not await self._queue_has_items(): return @@ -63,14 +61,14 @@ class JobManager: full_queue = await queue_manager.get_queue_items("full") if full_queue: logger.debug( - f"job_runner/full_queue at start: %s", + "job_runner/full_queue at start: %s", queue_manager.format_queue(full_queue), ) return True - else: - self.arr.tracker.reset() - logger.verbose(">>> Queue is empty.") - return False + + self.arr.tracker.reset() + logger.verbose(">>> Queue is empty.") + return False async def _qbit_connected(self): for qbit in self.settings.download_clients.qbittorrent: @@ -78,14 +76,14 @@ class JobManager: # Check if any client is disconnected if not await qbit.check_qbit_connected(): logger.warning( - f">>> qBittorrent is disconnected. Skipping queue cleaning on {self.arr.name}." + f">>> qBittorrent is disconnected. Skipping queue cleaning on {self.arr.name}.", ) return False return True def _get_removal_jobs(self): """ - Returns a list of enabled removal job instances based on the provided settings. + Return a list of enabled removal job instances based on the provided settings. Each job is included if the corresponding attribute exists and is truthy in settings.jobs. """ @@ -105,6 +103,6 @@ class JobManager: for removal_job_name, removal_job_class in removal_job_classes.items(): if getattr(self.settings.jobs, removal_job_name, False): jobs.append( - removal_job_class(self.arr, self.settings, removal_job_name) + removal_job_class(self.arr, self.settings, removal_job_name), ) return jobs diff --git a/src/jobs/__init__.py b/src/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/jobs/removal_handler.py b/src/jobs/removal_handler.py index 05f7167..9846245 100644 --- a/src/jobs/removal_handler.py +++ b/src/jobs/removal_handler.py @@ -1,5 +1,6 @@ from src.utils.log_setup import logger + class RemovalHandler: def __init__(self, arr, settings, job_name): self.arr = arr @@ -53,12 +54,12 @@ class RemovalHandler: if affected_download['protocol'] != 'torrent': return "remove" # handling is only implemented for torrent - client_implemenation = await self.arr.get_download_client_implementation(affected_download['downloadClient']) - if client_implemenation != "QBittorrent": + client_implementation = await self.arr.get_download_client_implementation(affected_download['downloadClient']) + if client_implementation != "QBittorrent": return "remove" # handling is only implemented for qbit if len(self.settings.download_clients.qbittorrent) == 0: - return "remove" # qbit not configured, thus can't tag + return "remove" # qbit not configured, thus can't tag if download_id in self.arr.tracker.private: return self.settings.general.private_tracker_handling diff --git a/src/jobs/removal_job.py b/src/jobs/removal_job.py index 8e0a98f..6a322d0 100644 --- a/src/jobs/removal_job.py +++ b/src/jobs/removal_job.py @@ -1,8 +1,9 @@ from abc import ABC, abstractmethod -from src.utils.queue_manager import QueueManager -from src.utils.log_setup import logger -from src.jobs.strikes_handler import StrikesHandler + from src.jobs.removal_handler import RemovalHandler +from src.jobs.strikes_handler import StrikesHandler +from src.utils.log_setup import logger +from src.utils.queue_manager import QueueManager class RemovalJob(ABC): @@ -16,14 +17,15 @@ class RemovalJob(ABC): queue = [] # Default class attributes (can be overridden in subclasses) - def __init__(self, arr, settings, job_name): + def __init__(self, arr, settings, job_name) -> None: self.arr = arr self.settings = settings self.job_name = job_name self.job = getattr(self.settings.jobs, self.job_name) self.queue_manager = QueueManager(self.arr, self.settings) - async def run(self): + + async def run(self) -> int: if not self.job.enabled: return 0 logger.debug(f"removal_job.py/run: Launching job '{self.job_name}', and checking if any items in {self.queue_scope} queue.") @@ -57,7 +59,8 @@ class RemovalJob(ABC): def _ignore_protected(self): """ - Filters out downloads that are in the protected tracker. + Filter out downloads that are in the protected tracker. + Directly updates self.affected_downloads. """ self.affected_downloads = { @@ -66,6 +69,6 @@ class RemovalJob(ABC): if download_id not in self.arr.tracker.protected } - @abstractmethod # Imlemented on level of each removal job - async def _find_affected_items(self): + @abstractmethod # Implemented on level of each removal job + async def _find_affected_items(self) -> None: pass diff --git a/src/jobs/remove_bad_files.py b/src/jobs/remove_bad_files.py index 2bf4e94..00657d8 100644 --- a/src/jobs/remove_bad_files.py +++ b/src/jobs/remove_bad_files.py @@ -1,32 +1,35 @@ -import os +from pathlib import Path + from src.jobs.removal_job import RemovalJob from src.utils.log_setup import logger + +# fmt: off +STANDARD_EXTENSIONS = [ + # Movies, TV Shows (Radarr, Sonarr, Whisparr) + ".webm", ".m4v", ".3gp", ".nsv", ".ty", ".strm", ".rm", ".rmvb", ".m3u", ".ifo", ".mov", ".qt", ".divx", ".xvid", ".bivx", ".nrg", ".pva", ".wmv", ".asf", ".asx", ".ogm", ".ogv", ".m2v", ".avi", ".bin", ".dat", ".dvr-ms", ".mpg", ".mpeg", ".mp4", ".avc", ".vp3", ".svq3", ".nuv", ".viv", ".dv", ".fli", ".flv", ".wpl", ".img", ".iso", ".vob", ".mkv", ".mk3d", ".ts", ".wtv", ".m2ts", + # Subs (Radarr, Sonarr, Whisparr) + ".sub", ".srt", ".idx", + # Audio (Lidarr, Readarr) + ".aac", ".aif", ".aiff", ".aifc", ".ape", ".flac", ".mp2", ".mp3", ".m4a", ".m4b", ".m4p", ".mp4a", ".oga", ".ogg", ".opus", ".vorbis", ".wma", ".wav", ".wv", "wavepack", + # Text (Readarr) + ".epub", ".kepub", ".mobi", ".azw3", ".pdf", +] + +# Archives can be handled by tools such as unpackerr: +ARCHIVE_EXTENSIONS = [ + ".rar", ".tar", ".tgz", ".gz", ".zip", ".7z", ".bz2", ".tbz2", ".iso", +] + +BAD_KEYWORDS = ["Sample", "Trailer"] +BAD_KEYWORD_LIMIT = 500 # Megabyte; do not remove items larger than that +# fmt: on + + class RemoveBadFiles(RemovalJob): queue_scope = "normal" blocklist = True - # fmt: off - good_extensions = [ - # Movies, TV Shows (Radarr, Sonarr, Whisparr) - ".webm", ".m4v", ".3gp", ".nsv", ".ty", ".strm", ".rm", ".rmvb", ".m3u", ".ifo", ".mov", ".qt", ".divx", ".xvid", ".bivx", ".nrg", ".pva", ".wmv", ".asf", ".asx", ".ogm", ".ogv", ".m2v", ".avi", ".bin", ".dat", ".dvr-ms", ".mpg", ".mpeg", ".mp4", ".avc", ".vp3", ".svq3", ".nuv", ".viv", ".dv", ".fli", ".flv", ".wpl", ".img", ".iso", ".vob", ".mkv", ".mk3d", ".ts", ".wtv", ".m2ts", - # Subs (Radarr, Sonarr, Whisparr) - ".sub", ".srt", ".idx", - # Audio (Lidarr, Readarr) - ".aac", ".aif", ".aiff", ".aifc", ".ape", ".flac", ".mp2", ".mp3", ".m4a", ".m4b", ".m4p", ".mp4a", ".oga", ".ogg", ".opus", ".vorbis", ".wma", ".wav", ".wv", "wavepack", - # Text (Readarr) - ".epub", ".kepub", ".mobi", ".azw3", ".pdf", - ] - - # Archives can be handled by tools such as unpackerr: - archive_extensions = [ - ".rar", ".tar", ".tgz", ".gz", ".zip", ".7z", ".bz2", ".tbz2", ".iso", - ] - - bad_keywords = ["Sample", "Trailer"] - bad_keyword_limit = 500 # Megabyte; do not remove items larger than that - # fmt: on - async def _find_affected_items(self): # Get in-scope download IDs result = self._group_download_ids_by_client() @@ -41,10 +44,12 @@ class RemoveBadFiles(RemovalJob): affected_items.extend(client_items) return affected_items - def _group_download_ids_by_client(self): - """Group all relevant download IDs by download client. - Limited to qbittorrent currently, as no other download clients implemented""" + """ + Group all relevant download IDs by download client. + + Limited to qbittorrent currently, as no other download clients implemented + """ result = {} for item in self.queue: @@ -62,7 +67,7 @@ class RemoveBadFiles(RemovalJob): result.setdefault(download_client, { "download_client_type": download_client_type, - "download_ids": set() + "download_ids": set(), })["download_ids"].add(item["downloadId"]) return result @@ -91,55 +96,48 @@ class RemoveBadFiles(RemovalJob): return affected_items - # -- Helper functions for qbit handling -- + # -- Helper functions for qbit handling -- def _get_items_to_process(self, qbit_items): - """Return only downloads that have metadata, are supposedly downloading. - Additionally, each dowload should be checked at least once (for bad extensions), and thereafter only if availabiliy drops to less than 100%""" + """ + Return only downloads that have metadata, are supposedly downloading. + + This is to prevent the case where a download has metadata but is not actually downloading. + Additionally, each download should be checked at least once (for bad extensions), and thereafter only if availability drops to less than 100% + """ return [ item for item in qbit_items if ( - item.get("has_metadata") - and item["state"] in {"downloading", "forcedDL", "stalledDL"} - and ( - item["hash"] not in self.arr.tracker.extension_checked - or item["availability"] < 1 - ) + item.get("has_metadata") + and item["state"] in {"downloading", "forcedDL", "stalledDL"} + and ( + item["hash"] not in self.arr.tracker.extension_checked + or item["availability"] < 1 + ) ) ] - - async def _get_active_files(self, qbit_client, torrent_hash): + @staticmethod + async def _get_active_files(qbit_client, torrent_hash) -> list[dict]: """Return only files from the torrent that are still set to download, with file extension and name.""" files = await qbit_client.get_torrent_files(torrent_hash) # Await the async method return [ { **f, # Include all original file properties - "file_name": os.path.basename(f["name"]), # Add proper filename (without folder) - "file_extension": os.path.splitext(f["name"])[1], # Add file_extension (e.g., .mp3) + "file_name": Path(f["name"]).name, # Add proper filename (without folder) + "file_extension": Path(f["name"]).suffix, # Add file_extension (e.g., .mp3) } for f in files if f["priority"] > 0 ] - def _log_stopped_files(self, stopped_files, torrent_name): + @staticmethod + def _log_stopped_files(stopped_files, torrent_name) -> None: logger.verbose( - f">>> Stopped downloading {len(stopped_files)} file{'s' if len(stopped_files) != 1 else ''} in: {torrent_name}" + f">>> Stopped downloading {len(stopped_files)} file{'s' if len(stopped_files) != 1 else ''} in: {torrent_name}", ) for file, reasons in stopped_files: logger.verbose(f">>> - {file['file_name']} ({' & '.join(reasons)})") - def _all_files_stopped(self, torrent_files): - """Check if no files remain with download priority.""" - return all(f["priority"] == 0 for f in torrent_files) - - def _match_queue_items(self, download_hash): - """Find matching queue item(s) by downloadId (uppercase).""" - return [ - item for item in self.queue - if item["downloadId"] == download_hash.upper() - ] - - def _get_stoppable_files(self, torrent_files): """Return files that can be marked as 'Do not Download' based on specific conditions.""" stoppable_files = [] @@ -156,7 +154,7 @@ class RemoveBadFiles(RemovalJob): # Check for bad keywords if self._contains_bad_keyword(file): reasons.append("Contains bad keyword in path") - + # Check if the file has low availability if self._is_complete_partial(file): reasons.append(f"Low availability: {file['availability'] * 100:.1f}%") @@ -167,16 +165,15 @@ class RemoveBadFiles(RemovalJob): return stoppable_files - - def _is_bad_extension(self, file): + def _is_bad_extension(self, file) -> bool: """Check if the file has a bad extension.""" - return file['file_extension'].lower() not in self.get_good_extensions() + return file["file_extension"].lower() not in self.get_good_extensions() def get_good_extensions(self): - extensions = list(self.good_extensions) + good_extensions = list(STANDARD_EXTENSIONS) if self.job.keep_archives: - extensions += self.archive_extensions - return extensions + good_extensions += ARCHIVE_EXTENSIONS + return good_extensions def _contains_bad_keyword(self, file): """Check if the file path contains a bad keyword and is smaller than the limit.""" @@ -184,33 +181,29 @@ class RemoveBadFiles(RemovalJob): file_size_mb = file.get("size", 0) / 1024 / 1024 return ( - any(keyword.lower() in file_path for keyword in self.bad_keywords) - and file_size_mb <= self.bad_keyword_limit + any(keyword.lower() in file_path for keyword in BAD_KEYWORDS) + and file_size_mb <= BAD_KEYWORD_LIMIT ) + @staticmethod + def _is_complete_partial(file) -> bool: + """Check if the availability is less than 100% and the file is not fully downloaded.""" + return file["availability"] < 1 and file["progress"] != 1 - def _is_complete_partial(self, file): - """Check if the availability is less than 100% and the file is not fully downloaded""" - if file["availability"] < 1 and not file["progress"] == 1: - return True - return False - - async def _mark_files_as_stopped(self, qbit_client, torrent_hash, stoppable_files): """Mark specific files as 'Do Not Download' in qBittorrent.""" - for file, _ in stoppable_files: - await qbit_client.set_torrent_file_priority(torrent_hash, file['index'], 0) + for file, _ in stoppable_files: + await qbit_client.set_torrent_file_priority(torrent_hash, file["index"], 0) - def _all_files_stopped(self, torrent_files, stoppable_files): + @staticmethod + def _all_files_stopped(torrent_files, stoppable_files) -> bool: """Check if all files are either stopped (priority 0) or in the stoppable files list.""" - stoppable_file_indexes= {file[0]["index"] for file in stoppable_files} + stoppable_file_indexes = {file[0]["index"] for file in stoppable_files} return all(f["priority"] == 0 or f["index"] in stoppable_file_indexes for f in torrent_files) - def _match_queue_items(self, download_hash): + def _match_queue_items(self, download_hash) -> list: """Find matching queue item(s) by downloadId (uppercase).""" return [ item for item in self.queue if item["downloadId"].upper() == download_hash.upper() ] - - diff --git a/src/jobs/remove_failed_downloads.py b/src/jobs/remove_failed_downloads.py index e757627..0fa274f 100644 --- a/src/jobs/remove_failed_downloads.py +++ b/src/jobs/remove_failed_downloads.py @@ -1,16 +1,9 @@ from src.jobs.removal_job import RemovalJob + class RemoveFailedDownloads(RemovalJob): queue_scope = "normal" blocklist = False async def _find_affected_items(self): - affected_items = [] - - for item in self.queue: - if "status" in item: - if item["status"] == "failed": - affected_items.append(item) - return affected_items - - + return self.queue_manager.filter_queue_by_status(self.queue, ["failed"]) diff --git a/src/jobs/remove_failed_imports.py b/src/jobs/remove_failed_imports.py index ce97860..4d02481 100644 --- a/src/jobs/remove_failed_imports.py +++ b/src/jobs/remove_failed_imports.py @@ -1,6 +1,8 @@ import fnmatch + from src.jobs.removal_job import RemovalJob + class RemoveFailedImports(RemovalJob): queue_scope = "normal" blocklist = True @@ -20,11 +22,12 @@ class RemoveFailedImports(RemovalJob): return affected_items - def _is_valid_item(self, item): + @staticmethod + def _is_valid_item(item) -> bool: """Check if item has the necessary fields and is in a valid state.""" # Required fields that must be present in the item required_fields = {"status", "trackedDownloadStatus", "trackedDownloadState", "statusMessages"} - + # Check if all required fields are present if not all(field in item for field in required_fields): return False @@ -34,35 +37,33 @@ class RemoveFailedImports(RemovalJob): return False # Check if the tracked download state is one of the allowed states - if item["trackedDownloadState"] not in {"importPending", "importFailed", "importBlocked"}: - return False - # If all checks pass, the item is valid - return True + return not (item["trackedDownloadState"] not in {"importPending", "importFailed", "importBlocked"}) - - def _prepare_removal_messages(self, item, patterns): + def _prepare_removal_messages(self, item, patterns) -> list[str]: """Prepare removal messages, adding the tracked download state and matching messages.""" messages = self._get_matching_messages(item["statusMessages"], patterns) if not messages: return [] - removal_messages = [f">>>>> Tracked Download State: {item['trackedDownloadState']}"] + messages - return removal_messages + return [f">>>>> Tracked Download State: {item['trackedDownloadState']}", *messages] - def _get_matching_messages(self, status_messages, patterns): + @staticmethod + def _get_matching_messages(status_messages, patterns) -> list: """Extract messages matching the provided patterns (or all messages if no pattern).""" matched_messages = [] - + if not patterns: # No patterns provided, include all messages for status_message in status_messages: matched_messages.extend(f">>>>> - {msg}" for msg in status_message.get("messages", [])) else: # Patterns provided, match only those messages that fit the patterns - for status_message in status_messages: - for msg in status_message.get("messages", []): - if any(fnmatch.fnmatch(msg, pattern) for pattern in patterns): - matched_messages.append(f">>>>> - {msg}") - - return matched_messages \ No newline at end of file + matched_messages.extend( + f">>>>> - {msg}" + for status_message in status_messages + for msg in status_message.get("messages", []) + if any(fnmatch.fnmatch(msg, pattern) for pattern in patterns) + ) + + return matched_messages diff --git a/src/jobs/remove_metadata_missing.py b/src/jobs/remove_metadata_missing.py index 7be4427..9767c8f 100644 --- a/src/jobs/remove_metadata_missing.py +++ b/src/jobs/remove_metadata_missing.py @@ -6,13 +6,5 @@ class RemoveMetadataMissing(RemovalJob): blocklist = True async def _find_affected_items(self): - affected_items = [] - - for item in self.queue: - if "errorMessage" in item and "status" in item: - if ( - item["status"] == "queued" - and item["errorMessage"] == "qBittorrent is downloading metadata" - ): - affected_items.append(item) - return affected_items + conditions = [("queued", "qBittorrent is downloading metadata")] + return self.queue_manager.filter_queue_by_status_and_error_message(self.queue, conditions) diff --git a/src/jobs/remove_missing_files.py b/src/jobs/remove_missing_files.py index 5d819f5..68ea994 100644 --- a/src/jobs/remove_missing_files.py +++ b/src/jobs/remove_missing_files.py @@ -1,5 +1,6 @@ from src.jobs.removal_job import RemovalJob + class RemoveMissingFiles(RemovalJob): queue_scope = "normal" blocklist = False @@ -8,12 +9,12 @@ class RemoveMissingFiles(RemovalJob): affected_items = [] for item in self.queue: - if self._is_failed_torrent(item) or self._is_bad_nzb(item): + if self._is_failed_torrent(item) or self._no_elibible_import(item): affected_items.append(item) - return affected_items - def _is_failed_torrent(self, item): + @staticmethod + def _is_failed_torrent(item) -> bool: return ( "status" in item and item["status"] == "warning" @@ -25,7 +26,8 @@ class RemoveMissingFiles(RemovalJob): ] ) - def _is_bad_nzb(self, item): + @staticmethod + def _no_elibible_import(item) -> bool: if "status" in item and item["status"] == "completed" and "statusMessages" in item: for status_message in item["statusMessages"]: if "messages" in status_message: diff --git a/src/jobs/remove_orphans.py b/src/jobs/remove_orphans.py index c7e3c6a..702b95b 100644 --- a/src/jobs/remove_orphans.py +++ b/src/jobs/remove_orphans.py @@ -1,10 +1,9 @@ from src.jobs.removal_job import RemovalJob + class RemoveOrphans(RemovalJob): queue_scope = "orphans" blocklist = False async def _find_affected_items(self): return self.queue - - diff --git a/src/jobs/remove_slow.py b/src/jobs/remove_slow.py index f7fc0e8..0e3e3e3 100644 --- a/src/jobs/remove_slow.py +++ b/src/jobs/remove_slow.py @@ -25,31 +25,34 @@ class RemoveSlow(RemovalJob): if self._is_completed_but_stuck(item): logger.info( - f">>> '{self.job_name}' detected download marked as slow as well as completed. Files most likely in process of being moved. Not removing: {item['title']}" + f">>> '{self.job_name}' detected download marked as slow as well as completed. Files most likely in process of being moved. Not removing: {item['title']}", ) continue downloaded, previous, increment, speed = await self._get_progress_stats( - item + item, ) if self._is_slow(speed): affected_items.append(item) logger.debug( f'remove_slow/slow speed detected: {item["title"]} ' f"(Speed: {speed} KB/s, KB now: {downloaded}, KB previous: {previous}, " - f"Diff: {increment}, In Minutes: {self.settings.general.timer})" + f"Diff: {increment}, In Minutes: {self.settings.general.timer})", ) return affected_items - def _is_valid_item(self, item): - required_keys = {"downloadId", "size", "sizeleft", "status", "protocol"} + @staticmethod + def _is_valid_item(item) -> bool: + required_keys = {"downloadId", "size", "sizeleft", "status", "protocol"} return required_keys.issubset(item) - def _is_usenet(self, item): + @staticmethod + def _is_usenet(item) -> bool: return item.get("protocol") == "usenet" - def _is_completed_but_stuck(self, item): + @staticmethod + def _is_completed_but_stuck(item) -> bool: return ( item["status"] == "downloading" and item["size"] > 0 @@ -67,7 +70,7 @@ class RemoveSlow(RemovalJob): download_progress = self._get_download_progress(item, download_id) previous_progress, increment, speed = self._compute_increment_and_speed( - download_id, download_progress + download_id, download_progress, ) self.arr.tracker.download_progress[download_id] = download_progress @@ -83,15 +86,18 @@ class RemoveSlow(RemovalJob): return progress return self._fallback_progress(item) - def _try_get_qbit_progress(self, qbit, download_id): + @staticmethod + def _try_get_qbit_progress(qbit, download_id): + # noinspection PyBroadException try: return qbit.get_download_progress(download_id) - except Exception: + except Exception: # noqa: BLE001 return None - def _fallback_progress(self, item): + @staticmethod + def _fallback_progress(item): logger.debug( - "get_progress_stats: Using imprecise method to determine download increments because either a different download client than qBitorrent is used, or the download client name in the config does not match with what is configured in your *arr download client settings" + "get_progress_stats: Using imprecise method to determine download increments because either a different download client than qBitorrent is used, or the download client name in the config does not match with what is configured in your *arr download client settings", ) return item["size"] - item["sizeleft"] diff --git a/src/jobs/remove_stalled.py b/src/jobs/remove_stalled.py index 13e68ae..50be79a 100644 --- a/src/jobs/remove_stalled.py +++ b/src/jobs/remove_stalled.py @@ -1,3 +1,5 @@ +"""Removes stalled downloads.""" + from src.jobs.removal_job import RemovalJob @@ -6,15 +8,5 @@ class RemoveStalled(RemovalJob): blocklist = True async def _find_affected_items(self): - affected_items = [] - for item in self.queue: - if "errorMessage" in item and "status" in item: - if ( - item["status"] == "warning" - and item["errorMessage"] - == "The download is stalled with no connections" - ): - affected_items.append(item) - return affected_items - - + conditions = [("warning", "The download is stalled with no connections")] + return self.queue_manager.filter_queue_by_status_and_error_message(self.queue, conditions) diff --git a/src/jobs/remove_unmonitored.py b/src/jobs/remove_unmonitored.py index ad20f5e..8b0d632 100644 --- a/src/jobs/remove_unmonitored.py +++ b/src/jobs/remove_unmonitored.py @@ -14,7 +14,6 @@ class RemoveUnmonitored(RemovalJob): # When queue item has been matched to artist (for instance in lidarr) but not yet to the detail (eg. album), then detail key is logically missing. # Thus we can't check if the item is monitored yet monitored_download_ids.append(item["downloadId"]) - # Second pass: Append queue items none that depends on download id is monitored affected_items = [] diff --git a/src/jobs/search_handler.py b/src/jobs/search_handler.py index c56e840..ae2d499 100644 --- a/src/jobs/search_handler.py +++ b/src/jobs/search_handler.py @@ -1,9 +1,10 @@ from datetime import datetime, timedelta, timezone + import dateutil.parser from src.utils.log_setup import logger -from src.utils.wanted_manager import WantedManager from src.utils.queue_manager import QueueManager +from src.utils.wanted_manager import WantedManager class SearchHandler: @@ -21,10 +22,10 @@ class SearchHandler: wanted_items = await self._get_initial_wanted_items(search_type) if not wanted_items: return - + logger.debug(f"search_handler.py/handle_search: Getting list of queue items to only search for items that are not already downloading.") queue = await QueueManager(self.arr, self.settings).get_queue_items( - queue_scope="normal" + queue_scope="normal", ) wanted_items = self._filter_wanted_items(wanted_items, queue) if not wanted_items: @@ -43,7 +44,8 @@ class SearchHandler: logger.verbose(f"Searching for unmet cutoff content on {self.arr.name}:") self.job = self.settings.jobs.search_unmet_cutoff_content else: - raise ValueError(f"Unknown search type: {search_type}") + error = f"Unknown search type: {search_type}" + raise ValueError(error) def _get_initial_wanted_items(self, search_type): wanted = self.wanted_manager.get_wanted_items(search_type) @@ -54,13 +56,13 @@ class SearchHandler: def _filter_wanted_items(self, items, queue): items = self._filter_already_downloading(items, queue) if not items: - logger.verbose(f">>> All items already downloading, nothing to search for.") + logger.verbose(">>> All items already downloading, nothing to search for.") return [] items = self._filter_recent_searches(items) if not items: logger.verbose( - f">>> All items recently searched for, thus not triggering another search." + ">>> All items recently searched for, thus not triggering another search.", ) return [] diff --git a/src/jobs/strikes_handler.py b/src/jobs/strikes_handler.py index a8b5f9a..c0998aa 100644 --- a/src/jobs/strikes_handler.py +++ b/src/jobs/strikes_handler.py @@ -8,7 +8,6 @@ class StrikesHandler: self.max_strikes = max_strikes self.tracker.defective.setdefault(job_name, {}) - def check_permitted_strikes(self, affected_downloads): self._recover_downloads(affected_downloads) return self._apply_strikes_and_filter(affected_downloads) @@ -27,10 +26,9 @@ class StrikesHandler: ) del self.tracker.defective[self.job_name][d_id] - def _apply_strikes_and_filter(self, affected_downloads): - for d_id, queue_items in list(affected_downloads.items()): - title = queue_items[0]["title"] + for d_id, affected_download in list(affected_downloads.items()): + title = affected_download["title"] strikes = self._increment_strike(d_id, title) strikes_left = self.max_strikes - strikes self._log_strike_status(title, strikes, strikes_left) @@ -39,10 +37,9 @@ class StrikesHandler: return affected_downloads - def _increment_strike(self, d_id, title): entry = self.tracker.defective[self.job_name].setdefault( - d_id, {"title": title, "strikes": 0} + d_id, {"title": title, "strikes": 0}, ) entry["strikes"] += 1 return entry["strikes"] @@ -58,7 +55,7 @@ class StrikesHandler: ">>> Job '%s' detected download (%s/%s strikes): %s", self.job_name, strikes, self.max_strikes, title, ) - elif strikes_left <= -2: + elif strikes_left <= -2: # noqa: PLR2004 logger.info( ">>> Job '%s' detected download (%s/%s strikes): %s", self.job_name, strikes, self.max_strikes, title, diff --git a/src/settings/__init__.py b/src/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/settings/_config_as_yaml.py b/src/settings/_config_as_yaml.py index 48cbecb..d272350 100644 --- a/src/settings/_config_as_yaml.py +++ b/src/settings/_config_as_yaml.py @@ -1,5 +1,6 @@ import yaml + def mask_sensitive_value(value, key, sensitive_attributes): """Mask the value if it's in the sensitive attributes.""" return "*****" if key in sensitive_attributes else value @@ -40,19 +41,19 @@ def clean_object(obj, sensitive_attributes, internal_attributes, hide_internal_a """Clean an object (either a dict, class instance, or other types).""" if isinstance(obj, dict): return clean_dict(obj, sensitive_attributes, internal_attributes, hide_internal_attr) - elif hasattr(obj, "__dict__"): + if hasattr(obj, "__dict__"): return clean_dict(vars(obj), sensitive_attributes, internal_attributes, hide_internal_attr) - else: - return mask_sensitive_value(obj, "", sensitive_attributes) + return mask_sensitive_value(obj, "", sensitive_attributes) def get_config_as_yaml( data, sensitive_attributes=None, internal_attributes=None, + *, hide_internal_attr=True, ): - """Main function to process the configuration into YAML format.""" + """Process the configuration into YAML format.""" if sensitive_attributes is None: sensitive_attributes = set() if internal_attributes is None: @@ -67,7 +68,7 @@ def get_config_as_yaml( # Process list-based config if isinstance(obj, list): cleaned_list = clean_list( - obj, sensitive_attributes, internal_attributes, hide_internal_attr + obj, sensitive_attributes, internal_attributes, hide_internal_attr, ) if cleaned_list: config_output[key] = cleaned_list @@ -75,7 +76,7 @@ def get_config_as_yaml( # Process dict or class-like object config else: cleaned_obj = clean_object( - obj, sensitive_attributes, internal_attributes, hide_internal_attr + obj, sensitive_attributes, internal_attributes, hide_internal_attr, ) if cleaned_obj: config_output[key] = cleaned_obj diff --git a/src/settings/_constants.py b/src/settings/_constants.py index 8d67a52..2397993 100644 --- a/src/settings/_constants.py +++ b/src/settings/_constants.py @@ -1,4 +1,5 @@ import os + from src.settings._config_as_yaml import get_config_as_yaml diff --git a/src/settings/_download_clients.py b/src/settings/_download_clients.py index 7dbbae3..4ecb8a4 100644 --- a/src/settings/_download_clients.py +++ b/src/settings/_download_clients.py @@ -1,12 +1,14 @@ from src.settings._config_as_yaml import get_config_as_yaml -from src.settings._download_clients_qBit import QbitClients +from src.settings._download_clients_qbit import QbitClients + +DOWNLOAD_CLIENT_TYPES = ["qbittorrent"] + class DownloadClients: """Represents all download clients.""" + qbittorrent = None - download_client_types = [ - "qbittorrent", - ] + def __init__(self, config, settings): self._set_qbit_clients(config, settings) self.check_unique_download_client_types() @@ -15,7 +17,7 @@ class DownloadClients: download_clients = config.get("download_clients", {}) if isinstance(download_clients, dict): self.qbittorrent = QbitClients(config, settings) - if not self.qbittorrent: # Unsets settings in general section needed for qbit (if no qbit is defined) + if not self.qbittorrent: # Unsets settings in general section needed for qbit (if no qbit is defined) for key in [ "private_tracker_handling", "public_tracker_handling", @@ -25,40 +27,42 @@ class DownloadClients: setattr(settings.general, key, None) def config_as_yaml(self): - """Logs all download clients.""" + """Log all download clients.""" return get_config_as_yaml( {"qbittorrent": self.qbittorrent}, sensitive_attributes={"username", "password", "cookie"}, - internal_attributes={ "api_url", "cookie", "settings", "min_version"}, - hide_internal_attr=True + internal_attributes={"api_url", "cookie", "settings", "min_version"}, + hide_internal_attr=True, ) - def check_unique_download_client_types(self): - """Ensures that all download client names are unique. - This is important since downloadClient in arr goes by name, and - this is needed to link it to the right IP set up in the yaml config - (which may be different to the one donfigured in arr)""" + """ + Ensure that all download client names are unique. + This is important since downloadClient in arr goes by name, and + this is needed to link it to the right IP set up in the yaml config + (which may be different to the one configured in arr) + """ seen = set() - for download_client_type in self.download_client_types: + for download_client_type in DOWNLOAD_CLIENT_TYPES: download_clients = getattr(self, download_client_type, []) # Check each client in the list for client in download_clients: name = getattr(client, "name", None) if name is None: - raise ValueError(f'{download_client_type} client does not have a name ({client.base_url}).\nMake sure that the name corresponds with the name set in your *arr app for that download client.') + error = f"{download_client_type} client does not have a name ({client.base_url}).\nMake sure that the name corresponds with the name set in your *arr app for that download client." + raise ValueError(error) if name.lower() in seen: - raise ValueError(f"Download client names must be unique. Duplicate name found: '{name}'\nMake sure that the name corresponds with the name set in your *arr app for that download client.") - else: - seen.add(name.lower()) + error = f"Download client names must be unique. Duplicate name found: '{name}'\nMake sure that the name corresponds with the name set in your *arr app for that download client." + raise ValueError(error) + seen.add(name.lower()) def get_download_client_by_name(self, name: str): """Retrieve the download client and its type by its name.""" name_lower = name.lower() - for download_client_type in self.download_client_types: + for download_client_type in DOWNLOAD_CLIENT_TYPES: download_clients = getattr(self, download_client_type, []) # Check each client in the list diff --git a/src/settings/_download_clients_qBit.py b/src/settings/_download_clients_qbit.py similarity index 92% rename from src/settings/_download_clients_qBit.py rename to src/settings/_download_clients_qbit.py index f65f1e5..15564bc 100644 --- a/src/settings/_download_clients_qBit.py +++ b/src/settings/_download_clients_qbit.py @@ -1,6 +1,7 @@ from packaging import version -from src.utils.common import make_request, wait_and_exit + from src.settings._constants import ApiEndpoints, MinVersions +from src.utils.common import make_request, wait_and_exit from src.utils.log_setup import logger @@ -9,7 +10,7 @@ class QbitError(Exception): class QbitClients(list): - """Represents all qBittorrent clients""" + """Represents all qBittorrent clients.""" def __init__(self, config, settings): super().__init__() @@ -20,7 +21,7 @@ class QbitClients(list): if not isinstance(qbit_config, list): logger.error( - "Invalid config format for qbittorrent clients. Expected a list." + "Invalid config format for qbittorrent clients. Expected a list.", ) return @@ -34,7 +35,7 @@ class QbitClients(list): class QbitClient: """Represents a single qBittorrent client.""" - cookie: str = None + cookie: dict[str, str] = None version: str = None def __init__( @@ -48,11 +49,12 @@ class QbitClient: self.settings = settings if not base_url: logger.error("Skipping qBittorrent client entry: 'base_url' is required.") - raise ValueError("qBittorrent client must have a 'base_url'.") + error = "qBittorrent client must have a 'base_url'." + raise ValueError(error) self.base_url = base_url.rstrip("/") - self.api_url = self.base_url + getattr(ApiEndpoints, "qbittorrent") - self.min_version = getattr(MinVersions, "qbittorrent") + self.api_url = self.base_url + ApiEndpoints.qbittorrent + self.min_version = MinVersions.qbittorrent self.username = username self.password = password self.name = name @@ -65,13 +67,18 @@ class QbitClient: self._remove_none_attributes() def _remove_none_attributes(self): - """Removes attributes that are None to keep the object clean.""" + """Remove attributes that are None to keep the object clean.""" for attr in list(vars(self)): if getattr(self, attr) is None: delattr(self, attr) async def refresh_cookie(self): """Refresh the qBittorrent session cookie.""" + + def _connection_error(): + error = "Login failed." + raise ConnectionError(error) + try: logger.debug( "_download_clients_qBit.py/refresh_cookie: Refreshing qBit cookie" @@ -92,7 +99,7 @@ class QbitClient: ) if response.text == "Fails.": - raise ConnectionError("Login failed.") + _connection_error() self.cookie = {"SID": response.cookies["SID"]} except Exception as e: @@ -118,14 +125,13 @@ class QbitClient: if version.parse(self.version) < version.parse(min_version): logger.error( - f"Please update qBittorrent to at least version {min_version}. Current version: {self.version}" - ) - raise QbitError( - f"qBittorrent version {self.version} is too old. Please update." + f"Please update qBittorrent to at least version {min_version}. Current version: {self.version}", ) + error = f"qBittorrent version {self.version} is too old. Please update." + raise QbitError(error) if version.parse(self.version) < version.parse("5.0.0"): logger.info( - f"[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead." + "[Tip!] Consider upgrading to qBittorrent v5.0.0 or newer to reduce network overhead.", ) async def create_tag(self, tag: str): @@ -166,13 +172,13 @@ class QbitClient: ) endpoint = f"{self.api_url}/app/preferences" response = await make_request( - "get", endpoint, self.settings, cookies=self.cookie + "get", endpoint, self.settings, cookies=self.cookie, ) qbit_settings = response.json() if not qbit_settings.get("use_unwanted_folder"): logger.info( - "Enabling 'Keep unselected files in .unwanted folder' in qBittorrent." + "Enabling 'Keep unselected files in .unwanted folder' in qBittorrent.", ) data = {"json": '{"use_unwanted_folder": true}'} await make_request( @@ -205,7 +211,7 @@ class QbitClient: ignore_test_run=True, ) - except Exception as e: + except Exception as e: # noqa: BLE001 tip = "💡 Tip: Did you specify the URL (and username/password if required) correctly?" logger.error(f"-- | qBittorrent\n❗️ {e}\n{tip}\n") wait_and_exit() @@ -227,8 +233,7 @@ class QbitClient: )["server_state"]["connection_status"] if qbit_connection_status == "disconnected": return False - else: - return True + return True async def setup(self): """Perform the qBittorrent setup by calling relevant managers.""" @@ -252,7 +257,7 @@ class QbitClient: await self.set_unwanted_folder() async def get_protected_and_private(self): - """Fetches torrents from qBittorrent and checks for protected and private status.""" + """Fetch torrents from qBittorrent and checks for protected and private status.""" protected_downloads = [] private_downloads = [] @@ -301,11 +306,12 @@ class QbitClient: async def set_tag(self, tags, hashes): """ - Sets tags to one or more torrents in qBittorrent. + Set tags to one or more torrents in qBittorrent. Args: tags (list): A list of tag names to be added. hashes (list): A list of torrent hashes to which the tags should be applied. + """ # Ensure hashes are provided as a string separated by '|' hashes_str = "|".join(hashes) diff --git a/src/settings/_general.py b/src/settings/_general.py index a4a975b..2095f24 100644 --- a/src/settings/_general.py +++ b/src/settings/_general.py @@ -1,11 +1,12 @@ -import yaml -from src.utils.log_setup import logger -from src.settings._validate_data_types import validate_data_types from src.settings._config_as_yaml import get_config_as_yaml +from src.settings._validate_data_types import validate_data_types +from src.utils.log_setup import logger + +VALID_TRACKER_HANDLING = {"remove", "skip", "obsolete_tag"} + class General: """Represents general settings for the application.""" - VALID_TRACKER_HANDLING = {"remove", "skip", "obsolete_tag"} log_level: str = "INFO" test_run: bool = False @@ -17,7 +18,6 @@ class General: obsolete_tag: str = None protected_tag: str = "Keep" - def __init__(self, config): general_config = config.get("general", {}) self.log_level = general_config.get("log_level", self.log_level.upper()) @@ -32,31 +32,31 @@ class General: self.protected_tag = general_config.get("protected_tag", self.protected_tag) # Validate tracker handling settings - self.private_tracker_handling = self._validate_tracker_handling( self.private_tracker_handling, "private_tracker_handling" ) - self.public_tracker_handling = self._validate_tracker_handling( self.public_tracker_handling, "public_tracker_handling" ) + self.private_tracker_handling = self._validate_tracker_handling(self.private_tracker_handling, "private_tracker_handling") + self.public_tracker_handling = self._validate_tracker_handling(self.public_tracker_handling, "public_tracker_handling") self.obsolete_tag = self._determine_obsolete_tag(self.obsolete_tag) - validate_data_types(self) self._remove_none_attributes() def _remove_none_attributes(self): - """Removes attributes that are None to keep the object clean.""" + """Remove attributes that are None to keep the object clean.""" for attr in list(vars(self)): if getattr(self, attr) is None: delattr(self, attr) - def _validate_tracker_handling(self, value, field_name): - """Validates tracker handling options. Defaults to 'remove' if invalid.""" - if value not in self.VALID_TRACKER_HANDLING: + @staticmethod + def _validate_tracker_handling(value, field_name) -> str: + """Validate tracker handling options. Defaults to 'remove' if invalid.""" + if value not in VALID_TRACKER_HANDLING: logger.error( - f"Invalid value '{value}' for {field_name}. Defaulting to 'remove'." + f"Invalid value '{value}' for {field_name}. Defaulting to 'remove'.", ) return "remove" return value def _determine_obsolete_tag(self, obsolete_tag): - """Defaults obsolete tag to "obsolete", only if none is provided and the tag is needed for handling """ + """Set obsolete tag to "obsolete", only if none is provided and the tag is needed for handling.""" if obsolete_tag is None and ( self.private_tracker_handling == "obsolete_tag" or self.public_tracker_handling == "obsolete_tag" @@ -65,10 +65,7 @@ class General: return obsolete_tag def config_as_yaml(self): - """Logs all general settings.""" - # yaml_output = yaml.dump(vars(self), indent=2, default_flow_style=False, sort_keys=False) - # logger.info(f"General Settings:\n{yaml_output}") - + """Log all general settings.""" return get_config_as_yaml( vars(self), - ) \ No newline at end of file + ) diff --git a/src/settings/_instances.py b/src/settings/_instances.py index 7b0d73a..7b5201e 100644 --- a/src/settings/_instances.py +++ b/src/settings/_instances.py @@ -1,16 +1,16 @@ import requests from packaging import version -from src.utils.log_setup import logger +from src.settings._config_as_yaml import get_config_as_yaml from src.settings._constants import ( ApiEndpoints, - MinVersions, - FullQueueParameter, DetailItemKey, DetailItemSearchCommand, + FullQueueParameter, + MinVersions, ) -from src.settings._config_as_yaml import get_config_as_yaml from src.utils.common import make_request, wait_and_exit +from src.utils.log_setup import logger class Tracker: @@ -63,9 +63,9 @@ class Instances: """Return a list of arr instances matching the given arr_type.""" return [arr for arr in self.arrs if arr.arr_type == arr_type] - def config_as_yaml(self, hide_internal_attr=True): - """Logs all configured Arr instances while masking sensitive attributes.""" - internal_attributes={ + def config_as_yaml(self, *, hide_internal_attr=True): + """Log all configured Arr instances while masking sensitive attributes.""" + internal_attributes = { "settings", "api_url", "min_version", @@ -92,8 +92,6 @@ class Instances: return "\n".join(outputs) - - def check_any_arrs(self): """Check if there are any ARR instances.""" if not self.arrs: @@ -128,12 +126,11 @@ class ArrInstances(list): arr_type=arr_type, base_url=client_config["base_url"], api_key=client_config["api_key"], - ) + ), ) except KeyError as e: - logger.error( - f"Missing required key {e} in {arr_type} client config." - ) + error = f"Missing required key {e} in {arr_type} client config." + logger.error(error) class ArrInstance: @@ -146,11 +143,13 @@ class ArrInstance: def __init__(self, settings, arr_type: str, base_url: str, api_key: str): if not base_url: logger.error(f"Skipping {arr_type} client entry: 'base_url' is required.") - raise ValueError(f"{arr_type} client must have a 'base_url'.") + error = f"{arr_type} client must have a 'base_url'." + raise ValueError(error) if not api_key: logger.error(f"Skipping {arr_type} client entry: 'api_key' is required.") - raise ValueError(f"{arr_type} client must have an 'api_key'.") + error = f"{arr_type} client must have an 'api_key'." + raise ValueError(error) self.settings = settings self.arr_type = arr_type @@ -164,6 +163,8 @@ class ArrInstance: self.detail_item_ids_key = self.detail_item_key + "Ids" self.detail_item_search_command = getattr(DetailItemSearchCommand, arr_type) + self.detail_item_search_command = getattr(DetailItemSearchCommand, arr_type) + async def _check_ui_language(self): """Check if the UI language is set to English.""" endpoint = self.api_url + "/config/ui" @@ -173,25 +174,26 @@ class ArrInstance: if ui_language > 1: # Not English logger.error("!! %s Error: !!", self.name) logger.error( - f"> Decluttarr only works correctly if UI language is set to English (under Settings/UI in {self.name})" + f"> Decluttarr only works correctly if UI language is set to English (under Settings/UI in {self.name})", ) logger.error( - "> Details: https://github.com/ManiMatter/decluttarr/issues/132)" + "> Details: https://github.com/ManiMatter/decluttarr/issues/132)", ) - raise ArrError("Not English") + error = "Not English" + raise ArrError(error) def _check_min_version(self, status): """Check if ARR instance meets minimum version requirements.""" self.version = status["version"] min_version = getattr(self.settings.min_versions, self.arr_type) - if min_version: - if version.parse(self.version) < version.parse(min_version): - logger.error("!! %s Error: !!", self.name) - logger.error( - f"> Please update {self.name} ({self.base_url}) to at least version {min_version}. Current version: {self.version}" - ) - raise ArrError("Not meeting minimum version requirements") + if min_version and version.parse(self.version) < version.parse(min_version): + logger.error("!! %s Error: !!", self.name) + logger.error( + f"> Please update {self.name} ({self.base_url}) to at least version {min_version}. Current version: {self.version}", + ) + error = f"Not meeting minimum version requirements: {min_version}" + logger.error(error) def _check_arr_type(self, status): """Check if the ARR instance is of the correct type.""" @@ -199,9 +201,10 @@ class ArrInstance: if actual_arr_type.lower() != self.arr_type: logger.error("!! %s Error: !!", self.name) logger.error( - f"> Your {self.name} ({self.base_url}) points to a {actual_arr_type} instance, rather than {self.arr_type}. Did you specify the wrong IP?" + f"> Your {self.name} ({self.base_url}) points to a {actual_arr_type} instance, rather than {self.arr_type}. Did you specify the wrong IP?", ) - raise ArrError("Wrong Arr Type") + error = "Wrong Arr Type" + logger.error(error) async def _check_reachability(self): """Check if ARR instance is reachable.""" @@ -210,14 +213,13 @@ class ArrInstance: endpoint = self.api_url + "/system/status" headers = {"X-Api-Key": self.api_key} response = await make_request( - "get", endpoint, self.settings, headers=headers, log_error=False + "get", endpoint, self.settings, headers=headers, log_error=False, ) - status = response.json() - return status + return response.json() except Exception as e: if isinstance(e, requests.exceptions.HTTPError): response = getattr(e, "response", None) - if response is not None and response.status_code == 401: + if response is not None and response.status_code == 401: # noqa: PLR2004 tip = "💡 Tip: Have you configured the API_KEY correctly?" else: tip = f"💡 Tip: HTTP error occurred. Status: {getattr(response, 'status_code', 'unknown')}" @@ -230,7 +232,7 @@ class ArrInstance: raise ArrError(e) from e async def setup(self): - """Checks on specific ARR instance""" + """Check on specific ARR instance.""" try: status = await self._check_reachability() self.name = status.get("instanceName", self.arr_type) @@ -242,7 +244,7 @@ class ArrInstance: logger.info(f"OK | {self.name} ({self.base_url})") logger.debug(f"Current version of {self.name}: {self.version}") - except Exception as e: + except Exception as e: # noqa: BLE001 if not isinstance(e, ArrError): logger.error(f"Unhandled error: {e}", exc_info=True) wait_and_exit() @@ -266,17 +268,19 @@ class ArrInstance: return client.get("implementation", None) return None - async def remove_queue_item(self, queue_id, blocklist=False): + async def remove_queue_item(self, queue_id, *, blocklist=False): """ - Remove a specific queue item from the queue by its qeue id. + Remove a specific queue item from the queue by its queue id. + Sends a delete request to the API to remove the item. Args: - queue_id (str): The quueue ID of the queue item to be removed. + queue_id (str): The queue ID of the queue item to be removed. blocklist (bool): Whether to add the item to the blocklist. Default is False. Returns: bool: Returns True if the removal was successful, False otherwise. + """ logger.debug(f"_instances.py/remove_queue_item: Removing queue item, blocklist: {blocklist}") endpoint = f"{self.api_url}/queue/{queue_id}" @@ -285,14 +289,11 @@ class ArrInstance: # Send the request to remove the download from the queue response = await make_request( - "delete", endpoint, self.settings, headers=headers, json=json_payload + "delete", endpoint, self.settings, headers=headers, json=json_payload, ) # If the response is successful, return True, else return False - if response.status_code == 200: - return True - else: - return False + return response.status_code == 200 # noqa: PLR2004 async def is_monitored(self, detail_id): """Check if detail item (like a book, series, etc) is monitored.""" diff --git a/src/settings/_jobs.py b/src/settings/_jobs.py index 62331f5..5048293 100644 --- a/src/settings/_jobs.py +++ b/src/settings/_jobs.py @@ -1,6 +1,6 @@ -from src.utils.log_setup import logger -from src.settings._validate_data_types import validate_data_types from src.settings._config_as_yaml import get_config_as_yaml +from src.settings._validate_data_types import validate_data_types +from src.utils.log_setup import logger class JobParams: @@ -36,7 +36,7 @@ class JobParams: self._remove_none_attributes() def _remove_none_attributes(self): - """Removes attributes that are None to keep the object clean.""" + """Remove attributes that are None to keep the object clean.""" for attr in list(vars(self)): if getattr(self, attr) is None: delattr(self, attr) @@ -56,16 +56,16 @@ class JobDefaults: job_defaults_config = config.get("job_defaults", {}) self.max_strikes = job_defaults_config.get("max_strikes", self.max_strikes) self.max_concurrent_searches = job_defaults_config.get( - "max_concurrent_searches", self.max_concurrent_searches + "max_concurrent_searches", self.max_concurrent_searches, ) self.min_days_between_searches = job_defaults_config.get( - "min_days_between_searches", self.min_days_between_searches + "min_days_between_searches", self.min_days_between_searches, ) validate_data_types(self) class Jobs: - """Represents all jobs explicitly""" + """Represent all jobs explicitly.""" def __init__(self, config): self.job_defaults = JobDefaults(config) @@ -79,10 +79,10 @@ class Jobs: ) self.remove_failed_downloads = JobParams() self.remove_failed_imports = JobParams( - message_patterns=self.job_defaults.message_patterns + message_patterns=self.job_defaults.message_patterns, ) self.remove_metadata_missing = JobParams( - max_strikes=self.job_defaults.max_strikes + max_strikes=self.job_defaults.max_strikes, ) self.remove_missing_files = JobParams() self.remove_orphans = JobParams() @@ -108,8 +108,7 @@ class Jobs: self._set_job_settings(job_name, config["jobs"][job_name]) def _set_job_settings(self, job_name, job_config): - """Sets per-job config settings""" - + """Set per-job config settings.""" job = getattr(self, job_name, None) if ( job_config is None @@ -134,8 +133,8 @@ class Jobs: setattr(self, job_name, job) validate_data_types( - job, self.job_defaults - ) # Validates and applies defauls from job_defaults + job, self.job_defaults, + ) # Validates and applies defaults from job_defaults def log_status(self): job_strings = [] @@ -158,7 +157,7 @@ class Jobs: ) def list_job_status(self): - """Returns a string showing each job and whether it's enabled or not using emojis.""" + """Return a string showing each job and whether it's enabled or not using emojis.""" lines = [] for name, obj in vars(self).items(): if hasattr(obj, "enabled"): diff --git a/src/settings/_user_config.py b/src/settings/_user_config.py index 4a4f2c9..e25b547 100644 --- a/src/settings/_user_config.py +++ b/src/settings/_user_config.py @@ -1,5 +1,7 @@ import os +from pathlib import Path import yaml + from src.utils.log_setup import logger CONFIG_MAPPING = { @@ -34,7 +36,8 @@ CONFIG_MAPPING = { def get_user_config(settings): - """Checks if data is read from enviornment variables, or from yaml file. + """ + Check if data is read from environment variables, or from yaml file. Reads from environment variables if in docker, unless in docker-compose "USE_CONFIG_YAML" is set to true. Then the config file is read. @@ -53,7 +56,7 @@ def get_user_config(settings): def _parse_env_var(key: str) -> dict | list | str | int | None: - """Helper function to parse one setting input key""" + """Parse one setting input key.""" raw_value = os.getenv(key) if raw_value is None: return None @@ -67,7 +70,7 @@ def _parse_env_var(key: str) -> dict | list | str | int | None: def _load_section(keys: list[str]) -> dict: - """Helper function to parse one section of expected config""" + """Parse one section of expected config.""" section_config = {} for key in keys: parsed = _parse_env_var(key) @@ -76,14 +79,6 @@ def _load_section(keys: list[str]) -> dict: return section_config -def _load_from_env() -> dict: - """Main function to load settings from env""" - config = {} - for section, keys in CONFIG_MAPPING.items(): - config[section] = _load_section(keys) - return config - - def _load_from_env() -> dict: config = {} @@ -100,7 +95,7 @@ def _load_from_env() -> dict: parsed_value = _lowercase(parsed_value) except yaml.YAMLError as e: logger.error( - f"Failed to parse environment variable {key} as YAML:\n{e}" + f"Failed to parse environment variable {key} as YAML:\n{e}", ) parsed_value = {} section_config[key.lower()] = parsed_value @@ -111,28 +106,26 @@ def _load_from_env() -> dict: def _lowercase(data): - """Translates recevied keys (for instance setting-keys of jobs) to lower case""" + """Translate received keys (for instance setting-keys of jobs) to lower case.""" if isinstance(data, dict): return {str(k).lower(): _lowercase(v) for k, v in data.items()} - elif isinstance(data, list): + if isinstance(data, list): return [_lowercase(item) for item in data] - else: - # Leave strings and other types unchanged - return data + # Leave strings and other types unchanged + return data def _config_file_exists(settings): config_path = settings.paths.config_file - return os.path.exists(config_path) + return Path(config_path).exists() def _load_from_yaml_file(settings): - """Reads config from YAML file and returns a dict.""" + """Read config from YAML file and returns a dict.""" config_path = settings.paths.config_file try: - with open(config_path, "r", encoding="utf-8") as file: - config = yaml.safe_load(file) or {} - return config + with Path(config_path).open(encoding="utf-8") as file: + return yaml.safe_load(file) or {} except yaml.YAMLError as e: logger.error("Error reading YAML file: %s", e) return {} diff --git a/src/settings/_validate_data_types.py b/src/settings/_validate_data_types.py index ab05ffc..5965dfc 100644 --- a/src/settings/_validate_data_types.py +++ b/src/settings/_validate_data_types.py @@ -1,14 +1,21 @@ - - import inspect + from src.utils.log_setup import logger + def validate_data_types(cls, default_cls=None): - """Ensures all attributes match expected types dynamically. + """ + Ensure all attributes match expected types dynamically. + If default_cls is provided, the default key is taken from this class rather than the own class If the attribute doesn't exist in `default_cls`, fall back to `cls.__class__`. """ + + def _unhandled_conversion(): + error = f"Unhandled type conversion for '{attr}': {expected_type}" + raise TypeError(error) + annotations = inspect.get_annotations(cls.__class__) # Extract type hints for attr, expected_type in annotations.items(): @@ -17,7 +24,7 @@ def validate_data_types(cls, default_cls=None): value = getattr(cls, attr) default_source = default_cls if default_cls and hasattr(default_cls, attr) else cls.__class__ - default_value = getattr(default_source, attr, None) + default_value = getattr(default_source, attr, None) if value == default_value: continue @@ -37,22 +44,20 @@ def validate_data_types(cls, default_cls=None): elif expected_type is dict: value = convert_to_dict(value) else: - raise TypeError(f"Unhandled type conversion for '{attr}': {expected_type}") - except Exception as e: - + _unhandled_conversion() + except Exception as e: # noqa: BLE001 logger.error( f"❗️ Invalid type for '{attr}': Expected {expected_type.__name__}, but got {type(value).__name__}. " - f"Error: {e}. Using default value: {default_value}" + f"Error: {e}. Using default value: {default_value}", ) value = default_value setattr(cls, attr, value) - # --- Helper Functions --- def convert_to_bool(raw_value): - """Converts strings like 'yes', 'no', 'true', 'false' into boolean values.""" + """Convert strings like 'yes', 'no', 'true', 'false' into boolean values.""" if isinstance(raw_value, bool): return raw_value @@ -64,28 +69,29 @@ def convert_to_bool(raw_value): if raw_value in true_values: return True - elif raw_value in false_values: + if raw_value in false_values: return False - else: - raise ValueError(f"Invalid boolean value: '{raw_value}'") + error = f"Invalid boolean value: '{raw_value}'" + raise ValueError(error) def convert_to_str(raw_value): - """Ensures a string and trims whitespace.""" + """Ensure a string and trims whitespace.""" if isinstance(raw_value, str): return raw_value.strip() return str(raw_value).strip() def convert_to_list(raw_value): - """Ensures a value is a list.""" + """Ensure a value is a list.""" if isinstance(raw_value, list): return [convert_to_str(item) for item in raw_value] return [convert_to_str(raw_value)] # Wrap single values in a list def convert_to_dict(raw_value): - """Ensures a value is a dictionary.""" + """Ensure a value is a dictionary.""" if isinstance(raw_value, dict): return {convert_to_str(k): v for k, v in raw_value.items()} - raise TypeError(f"Expected dict but got {type(raw_value).__name__}") + error = f"Expected dict but got {type(raw_value).__name__}" + raise TypeError(error) diff --git a/src/settings/settings.py b/src/settings/settings.py index a028fd8..e605cd9 100644 --- a/src/settings/settings.py +++ b/src/settings/settings.py @@ -1,14 +1,14 @@ -from src.utils.log_setup import configure_logging from src.settings._constants import Envs, MinVersions, Paths -# from src.settings._migrate_legacy import migrate_legacy -from src.settings._general import General -from src.settings._jobs import Jobs from src.settings._download_clients import DownloadClients +from src.settings._general import General from src.settings._instances import Instances +from src.settings._jobs import Jobs from src.settings._user_config import get_user_config +from src.utils.log_setup import configure_logging + class Settings: - + min_versions = MinVersions() paths = Paths() @@ -21,7 +21,6 @@ class Settings: self.instances = Instances(config, self) configure_logging(self) - def __repr__(self): sections = [ ("ENVIRONMENT SETTINGS", "envs"), @@ -30,11 +29,8 @@ class Settings: ("JOB SETTINGS", "jobs"), ("INSTANCE SETTINGS", "instances"), ("DOWNLOAD CLIENT SETTINGS", "download_clients"), - ] - messages = [] - messages.append("🛠️ Decluttarr - Settings 🛠️") - messages.append("-"*80) - # messages.append("") + ] + messages = ["🛠️ Decluttarr - Settings 🛠️", "-" * 80] for title, attr_name in sections: section = getattr(self, attr_name, None) section_content = section.config_as_yaml() @@ -44,18 +40,14 @@ class Settings: elif section_content != "{}": messages.append(self._format_section_title(title)) messages.append(section_content) - messages.append("") # Extra linebreak after section + messages.append("") # Extra linebreak after section return "\n".join(messages) - - def _format_section_title(self, name, border_length=50, symbol="="): + @staticmethod + def _format_section_title(name, border_length=50, symbol="=") -> str: """Format section title with centered name and hash borders.""" padding = max(border_length - len(name) - 2, 0) # 4 for spaces left_hashes = right_hashes = padding // 2 if padding % 2 != 0: right_hashes += 1 return f"{symbol * left_hashes} {name} {symbol * right_hashes}" - - - - diff --git a/src/utils/__init__.py b/src/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/utils/common.py b/src/utils/common.py index ab67a7c..2137afe 100644 --- a/src/utils/common.py +++ b/src/utils/common.py @@ -1,9 +1,10 @@ +import asyncio import sys import time -import asyncio import logging import copy import requests + from src.utils.log_setup import logger @@ -27,13 +28,13 @@ def sanitize_kwargs(data): else: redacted[key] = sanitize_kwargs(value) return redacted - elif isinstance(data, list): + if isinstance(data, list): return [sanitize_kwargs(item) for item in data] return data async def make_request( - method: str, endpoint: str, settings, timeout: int = 15, log_error = True, **kwargs + method: str, endpoint: str, settings, timeout: int = 15, *, log_error=True, **kwargs, ) -> requests.Response: """ A utility function to make HTTP requests (GET, POST, DELETE, PUT). @@ -56,7 +57,7 @@ async def make_request( logger.debug( f"common.py/make_request: Making {method.upper()} request to {endpoint} with kwargs={sanitized_kwargs}" ) - + # Make the request using the method passed (get, post, etc.) response = await asyncio.to_thread( getattr(requests, method.lower()), diff --git a/src/utils/log_setup.py b/src/utils/log_setup.py index d4692fd..1249b2d 100644 --- a/src/utils/log_setup.py +++ b/src/utils/log_setup.py @@ -1,6 +1,6 @@ import logging -import os from logging.handlers import RotatingFileHandler +from pathlib import Path # Track added logging levels _added_levels = {} @@ -9,7 +9,8 @@ _added_levels = {} def add_logging_level(level_name, level_num): """Dynamically add a custom logging level.""" if level_name in _added_levels or level_num in _added_levels.values(): - raise ValueError(f"Logging level '{level_name}' or number '{level_num}' already exists.") + error = f"Logging level '{level_name}' or number '{level_num}' already exists." + raise ValueError(error) logging.addLevelName(level_num, level_name.upper()) @@ -26,41 +27,41 @@ def add_logging_level(level_name, level_num): add_logging_level("TRACE", 5) add_logging_level("VERBOSE", 15) - # Configure the default logger logger = logging.getLogger(__name__) -def set_handler_format(log_handler, long_format = True): + +def set_handler_format(log_handler, *, long_format=True): if long_format: target_format = logging.Formatter("%(asctime)s | %(levelname)-7s | %(message)s", "%Y-%m-%d %H:%M:%S") else: target_format = logging.Formatter("%(levelname)-7s | %(message)s") log_handler.setFormatter(target_format) + # Default console handler console_handler = logging.StreamHandler() -set_handler_format(console_handler, long_format = True) +set_handler_format(console_handler, long_format=True) logger.addHandler(console_handler) logger.setLevel(logging.INFO) - def configure_logging(settings): """Add a file handler and adjust log levels for all handlers.""" if settings.envs.in_docker: - set_handler_format(console_handler, long_format = False) + set_handler_format(console_handler, long_format=False) log_file = settings.paths.logs - log_dir = os.path.dirname(log_file) - os.makedirs(log_dir, exist_ok=True) + log_dir = Path(log_file).parent + Path(log_dir).mkdir(exist_ok=True, parents=True) # File handler file_handler = RotatingFileHandler(log_file, maxBytes=50 * 1024 * 1024, backupCount=2) - set_handler_format(file_handler, long_format = True) + set_handler_format(file_handler, long_format=True) logger.addHandler(file_handler) # Update log level for all handlers log_level = getattr(logging, settings.general.log_level.upper(), logging.INFO) for handler in logger.handlers: handler.setLevel(log_level) - logger.setLevel(log_level) \ No newline at end of file + logger.setLevel(log_level) diff --git a/src/utils/queue_manager.py b/src/utils/queue_manager.py index 47209cd..9d523fe 100644 --- a/src/utils/queue_manager.py +++ b/src/utils/queue_manager.py @@ -1,6 +1,6 @@ import logging -from src.utils.log_setup import logger from src.utils.common import make_request +from src.utils.log_setup import logger class QueueManager: @@ -10,7 +10,8 @@ class QueueManager: async def get_queue_items(self, queue_scope): """ - Retrieves queue items based on the scope. + Retrieve queue items based on the scope. + queue_scope: "normal" = normal queue "orphans" = orphaned queue items (in full queue but not in normal queue) @@ -25,12 +26,13 @@ class QueueManager: elif queue_scope == "full": queue_items = await self._get_queue(full_queue=True) else: - raise ValueError(f"Invalid queue_scope: {queue_scope}") + error = f"Invalid queue_scope: {queue_scope}" + raise ValueError(error) if logger.isEnabledFor(logging.DEBUG): logger.debug("queue_manager.py/get_queue_items/queue (%s): %s", queue_scope, self.format_queue(queue_items)) return queue_items - async def _get_queue(self, full_queue=False): + async def _get_queue(self, *, full_queue=False): # Step 1: Refresh the queue (now internal) await self._refresh_queue() @@ -43,15 +45,14 @@ class QueueManager: # Step 4: Filter the queue based on delayed items and ignored download clients queue = self._filter_out_ignored_statuses(queue) queue = self._filter_out_ignored_download_clients(queue) - queue = self._add_detail_item_key(queue) - return queue + return self._add_detail_item_key(queue) def _add_detail_item_key(self, queue): - """Normalizes episodeID, bookID, etc so it can just be called by 'detail_item_id'""" + """Normalize episodeID, bookID, etc. so it can just be called by 'detail_item_id'.""" for items in queue: - items["detail_item_id"] = items.get(self.arr.detail_item_id_key) + items["detail_item_id"] = items.get(self.arr.detail_item_id_key) return queue - + async def _refresh_queue(self): # Refresh the queue by making the POST request using an external make_request function await make_request( @@ -88,7 +89,7 @@ class QueueManager: records = ( await make_request( method="GET", - endpoint=f"{self.arr.api_url}/queue", + endpoint=f"{self.arr.api_url}/queue", settings=self.settings, params=params, headers={"X-Api-Key": self.arr.api_key}, @@ -110,8 +111,7 @@ class QueueManager: list[dict]: Filtered queue. """ if queue is None: - return queue - + return None seen_combinations = set() filtered_queue = [] @@ -152,7 +152,7 @@ class QueueManager: return filtered_queue - def format_queue(self, queue_items): + def format_queue(self, queue_items) -> list | str: if not queue_items: return "empty" return self.group_by_download_id(queue_items) @@ -192,3 +192,20 @@ class QueueManager: } return grouped_dict + + @staticmethod + def filter_queue_by_status(queue, statuses: list[str]) -> list[dict]: + """Filter queue items that match any of the given statuses.""" + return [item for item in queue if item.get("status") in statuses] + + @staticmethod + def filter_queue_by_status_and_error_message(queue, conditions: list[tuple[str, str]]) -> list[dict]: + """Filter queue items that match any given (status, errorMessage) pair.""" + queue_items = [] + for item in queue: + if "errorMessage" in item and "status" in item: + for status, message in conditions: + if item["status"] == status and item["errorMessage"] == message: + queue_items.append(item) + break # Stop checking other conditions once one matches + return queue_items diff --git a/src/utils/startup.py b/src/utils/startup.py index d851812..1b8feff 100644 --- a/src/utils/startup.py +++ b/src/utils/startup.py @@ -1,49 +1,50 @@ import warnings + from src.utils.log_setup import logger + def show_welcome(settings): - messages = [] + messages = ["🎉🎉🎉 Decluttarr - Application Started! 🎉🎉🎉", + "-" * 80, + "⭐️ Like this app?", + "Thanks for giving it a ⭐️ on GitHub!", + "https://github.com/ManiMatter/decluttarr/"] # Show welcome message - messages.append("🎉🎉🎉 Decluttarr - Application Started! 🎉🎉🎉") - messages.append("-"*80) - # messages.append("") - messages.append("⭐️ Like this app?") - messages.append("Thanks for giving it a ⭐️ on GitHub!") - messages.append("https://github.com/ManiMatter/decluttarr/") # Show info level tip if settings.general.log_level == "INFO": - # messages.append("") - messages.append("") - messages.append("💡 Tip: More logs?") - messages.append("If you want to know more about what's going on, switch log level to 'VERBOSE'") + messages.extend([ + "", + "💡 Tip: More logs?", + "If you want to know more about what's going on, switch log level to 'VERBOSE'", + ]) # Show bug report tip - # messages.append("") - messages.append("") - messages.append("🐛 Found a bug?") - messages.append("Before reporting bugs on GitHub, please:") - messages.append("1) Check the readme on github") - messages.append("2) Check open and closed issues on github") - messages.append("3) Switch your logs to 'DEBUG' level") - messages.append("4) Turn off any features other than the one(s) causing it") - messages.append("5) Provide the full logs via pastebin on your GitHub issue") - messages.append("Once submitted, thanks for being responsive and helping debug / re-test") - + messages.extend([ + "", + "🐛 Found a bug?", + "Before reporting bugs on GitHub, please:", + "1) Check the readme on github", + "2) Check open and closed issues on github", + "3) Switch your logs to 'DEBUG' level", + "4) Turn off any features other than the one(s) causing it", + "5) Provide the full logs via pastebin on your GitHub issue", + "Once submitted, thanks for being responsive and helping debug / re-test", + ]) # Show test mode tip if settings.general.test_run: - # messages.append("") - messages.append("") - messages.append("=================== IMPORTANT ====================") - messages.append(" ⚠️ ⚠️ ⚠️ TEST MODE IS ACTIVE ⚠️ ⚠️ ⚠️") - messages.append("Decluttarr won't actually do anything for you...") - messages.append("You can change this via the setting 'test_run'") - messages.append("==================================================") + messages.extend([ + "", + "=================== IMPORTANT ====================", + " ⚠️ ⚠️ ⚠️ TEST MODE IS ACTIVE ⚠️ ⚠️ ⚠️", + "Decluttarr won't actually do anything for you...", + "You can change this via the setting 'test_run'", + "==================================================", + ]) messages.append("") - # messages.append("-"*80) # Log all messages at once logger.info("\n".join(messages)) diff --git a/src/utils/wanted_manager.py b/src/utils/wanted_manager.py index 80f449a..329d86e 100644 --- a/src/utils/wanted_manager.py +++ b/src/utils/wanted_manager.py @@ -8,12 +8,12 @@ class WantedManager: async def get_wanted_items(self, missing_or_cutoff): """ - Retrieves wanted items : - missing_or_cutoff: Drives whether missing or cutoff items are retrieved + Retrieve wanted items. + + missing_or_cutoff: Drives whether missing or cutoff items are retrieved """ record_count = await self._get_total_records(missing_or_cutoff) - missing_or_cutoff = await self._get_arr_records(missing_or_cutoff, record_count) - return missing_or_cutoff + return await self._get_arr_records(missing_or_cutoff, record_count) async def _get_total_records(self, missing_or_cutoff): # Get the total number of records from wanted @@ -46,9 +46,8 @@ class WantedManager: ).json() return records["records"] - async def search_items(self, detail_ids): - """Search items by detail IDs""" + """Search items by detail IDs.""" if isinstance(detail_ids, str): detail_ids = [detail_ids] @@ -62,4 +61,4 @@ class WantedManager: settings=self.settings, json=json, headers={"X-Api-Key": self.arr.api_key}, - ) \ No newline at end of file + ) diff --git a/tests/jobs/__init__.py b/tests/jobs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/jobs/test_removal_handler.py b/tests/jobs/test_removal_handler.py index 5ae7ea7..21a2e84 100644 --- a/tests/jobs/test_removal_handler.py +++ b/tests/jobs/test_removal_handler.py @@ -1,7 +1,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest -from src.jobs.removal_handler import RemovalHandler +from src.jobs.removal_handler import RemovalHandler @pytest.mark.parametrize( @@ -12,8 +12,8 @@ from src.jobs.removal_handler import RemovalHandler (False, True, "QBittorrent", "torrent", "remove"), (False, False, "QBittorrent", "torrent", "remove"), (True, False, "Transmission", "torrent", "remove"), # unsupported client - (True, False, "MyUseNetClient", "usenet", "remove"), # unsupported protocol - ] + (True, False, "MyUseNetClient", "usenet", "remove"), # unsupported protocol + ], ) @pytest.mark.asyncio async def test_get_handling_method( @@ -41,6 +41,7 @@ async def test_get_handling_method( "protocol": protocol, } - result = await handler._get_handling_method("A", affected_download) # pylint: disable=protected-access + result = await handler._get_handling_method( # pylint: disable=W0212 + "A", affected_download + ) assert result == expected - diff --git a/tests/jobs/test_remove_bad_files.py b/tests/jobs/test_remove_bad_files.py index 1d8528c..153e476 100644 --- a/tests/jobs/test_remove_bad_files.py +++ b/tests/jobs/test_remove_bad_files.py @@ -1,6 +1,7 @@ +from pathlib import Path from unittest.mock import MagicMock, AsyncMock -import os import pytest + from src.jobs.remove_bad_files import RemoveBadFiles # Fixture for arr mock @@ -35,14 +36,15 @@ def test_is_bad_extension(removal_job, file_name, expected_result, keep_archives # Act file = {"name": file_name} # Simulating a file object - file["file_extension"] = os.path.splitext(file["name"])[1].lower() + file["file_extension"] = Path(file["name"]).suffix.lower() result = removal_job._is_bad_extension(file) # pylint: disable=W0212 # Assert assert result == expected_result + @pytest.mark.parametrize( - "name, size_bytes, expected_result", + ("name", "size_bytes", "expected_result"), [ ("My.Movie.2024.2160/Subfolder/sample.mkv", 100 * 1024, True), # 100 KB, 'sample' keyword in filename ("My.Movie.2024.2160/Subfolder/Sample.mkv", 100 * 1024, True), # 100 KB, case-insensitive match @@ -69,16 +71,16 @@ def test_contains_bad_keyword(removal_job, name, size_bytes, expected_result): @pytest.mark.parametrize( - "file, is_incomplete_partial", + ("file", "is_incomplete_partial"), [ ({"availability": 1, "progress": 1}, False), # Fully available ({"availability": 0.5, "progress": 0.5}, True), # Low availability - ( {"availability": 0.5, "progress": 1}, False,), # Downloaded, low availability + ({"availability": 0.5, "progress": 1}, False), # Downloaded, low availability ({"availability": 0.9, "progress": 0.8}, True), # Low availability ], ) def test_is_complete_partial(removal_job, file, is_incomplete_partial): - """This test checks if the availability logic works correctly.""" + """Check if the availability logic works correctly.""" # Act result = removal_job._is_complete_partial(file) # pylint: disable=W0212 @@ -87,7 +89,7 @@ def test_is_complete_partial(removal_job, file, is_incomplete_partial): @pytest.mark.parametrize( - "qbit_item, expected_processed", + ("qbit_item", "expected_processed"), [ # Case 1: Torrent without metadata ( @@ -181,7 +183,7 @@ async def test_get_items_to_process(qbit_item, expected_processed, removal_job): # Act processed_items = removal_job._get_items_to_process( # pylint: disable=W0212 [qbit_item] - ) + ) # Extract the hash from the processed items processed_hashes = [item["hash"] for item in processed_items] @@ -194,7 +196,7 @@ async def test_get_items_to_process(qbit_item, expected_processed, removal_job): @pytest.mark.parametrize( - "file, should_be_stoppable", + ("file", "should_be_stoppable"), [ # Stopped files - No need to stop again ( @@ -217,7 +219,7 @@ async def test_get_items_to_process(qbit_item, expected_processed, removal_job): }, False, ), - # Bad file extension – Always stop (if not alredy stopped) + # Bad file extension - Always stop (if not alredy stopped) ( { "index": 0, @@ -248,7 +250,7 @@ async def test_get_items_to_process(qbit_item, expected_processed, removal_job): }, True, ), - # Good file extension – Stop only if availability < 1 **and** progress < 1 + # Good file extension - Stop only if availability < 1 **and** progress < 1 ( { "index": 0, @@ -292,8 +294,8 @@ async def test_get_items_to_process(qbit_item, expected_processed, removal_job): ], ) def test_get_stoppable_file_single(removal_job, file, should_be_stoppable): - # Add file_extension based on the file name - file["file_extension"] = os.path.splitext(file["name"])[1].lower() + # Add file_extension based on the file name + file["file_extension"] = Path(file["name"]).suffix.lower() stoppable = removal_job._get_stoppable_files([file]) # pylint: disable=W0212 is_stoppable = bool(stoppable) assert is_stoppable == should_be_stoppable @@ -311,7 +313,7 @@ def fixture_torrent_files(): @pytest.mark.parametrize( - "stoppable_indexes, all_files_stopped", + ("stoppable_indexes", "all_files_stopped"), [ ([0], False), # Case 1: Nothing changes (stopping an already stopped file) ([2], False), # Case 2: One additional file stopped @@ -320,7 +322,7 @@ def fixture_torrent_files(): ], ) def test_all_files_stopped( - removal_job, torrent_files, stoppable_indexes, all_files_stopped + removal_job, torrent_files, stoppable_indexes, all_files_stopped, ): # Create stoppable_files using only the index for each file and a dummy reason stoppable_files = [({"index": idx}, "some reason") for idx in stoppable_indexes] diff --git a/tests/jobs/test_remove_failed_downloads.py b/tests/jobs/test_remove_failed_downloads.py index 38e10a1..1921dde 100644 --- a/tests/jobs/test_remove_failed_downloads.py +++ b/tests/jobs/test_remove_failed_downloads.py @@ -1,49 +1,42 @@ -from unittest.mock import MagicMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_failed_downloads import RemoveFailedDownloads + # Test to check if items with "failed" status are included in affected items with parameterized data @pytest.mark.asyncio @pytest.mark.parametrize( - "queue_data, expected_download_ids", + ("queue_data", "expected_download_ids"), [ ( [ {"downloadId": "1", "status": "failed"}, # Item with failed status {"downloadId": "2", "status": "completed"}, # Item with completed status - {"downloadId": "3"} # No status field + {"downloadId": "3"}, # No status field ], - ["1"] # Only the failed item should be affected + ["1"], # Only the failed item should be affected ), ( [ {"downloadId": "1", "status": "completed"}, # Item with completed status {"downloadId": "2", "status": "completed"}, - {"downloadId": "3", "status": "completed"} + {"downloadId": "3", "status": "completed"}, ], - [] # No failed items, so no affected items + [], # No failed items, so no affected items ), ( [ {"downloadId": "1", "status": "failed"}, # Item with failed status - {"downloadId": "2", "status": "failed"} + {"downloadId": "2", "status": "failed"}, ], - ["1", "2"] # Both failed items should be affected - ) - ] + ["1", "2"], # Both failed items should be affected + ), + ], ) async def test_find_affected_items(queue_data, expected_download_ids): # Arrange - removal_job = RemoveFailedDownloads(arr=MagicMock(), settings=MagicMock(), job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveFailedDownloads, queue_data) - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - assert isinstance(affected_items, list) - - # Assert that the affected items match the expected download IDs - affected_download_ids = [item["downloadId"] for item in affected_items] - assert sorted(affected_download_ids) == sorted(expected_download_ids), \ - f"Expected affected items with downloadIds {expected_download_ids}, got {affected_download_ids}" + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_failed_imports.py b/tests/jobs/test_remove_failed_imports.py index b834584..f67373e 100644 --- a/tests/jobs/test_remove_failed_imports.py +++ b/tests/jobs/test_remove_failed_imports.py @@ -1,10 +1,13 @@ from unittest.mock import MagicMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_failed_imports import RemoveFailedImports + @pytest.mark.asyncio @pytest.mark.parametrize( - "item, expected_result", + ("item", "expected_result"), [ # Valid item scenario ( @@ -14,7 +17,7 @@ from src.jobs.remove_failed_imports import RemoveFailedImports "trackedDownloadState": "importPending", "statusMessages": [{"messages": ["Import failed"]}], }, - True + True, ), # Invalid item with wrong status ( @@ -24,7 +27,7 @@ from src.jobs.remove_failed_imports import RemoveFailedImports "trackedDownloadState": "importPending", "statusMessages": [{"messages": ["Import failed"]}], }, - False + False, ), # Invalid item with missing required fields ( @@ -33,7 +36,7 @@ from src.jobs.remove_failed_imports import RemoveFailedImports "trackedDownloadState": "importPending", "statusMessages": [{"messages": ["Import failed"]}], }, - False + False, ), # Invalid item with wrong trackedDownloadStatus ( @@ -43,7 +46,7 @@ from src.jobs.remove_failed_imports import RemoveFailedImports "trackedDownloadState": "importPending", "statusMessages": [{"messages": ["Import failed"]}], }, - False + False, ), # Invalid item with wrong trackedDownloadState ( @@ -53,23 +56,23 @@ from src.jobs.remove_failed_imports import RemoveFailedImports "trackedDownloadState": "downloaded", "statusMessages": [{"messages": ["Import failed"]}], }, - False + False, ), - ] + ], ) async def test_is_valid_item(item, expected_result): - #Fix - removal_job = RemoveFailedImports(arr=MagicMock(), settings=MagicMock(), job_name="test") + # Fix + removal_job = RemoveFailedImports( + arr=MagicMock(), settings=MagicMock(), job_name="test" + ) # Act - result = removal_job._is_valid_item(item) # pylint: disable=W0212 + result = removal_job._is_valid_item(item) # pylint: disable=W0212 # Assert assert result == expected_result - - # Fixture with 3 valid items with different messages and downloadId @pytest.fixture(name="queue_data") def fixture_queue_data(): @@ -94,48 +97,68 @@ def fixture_queue_data(): "trackedDownloadStatus": "warning", "trackedDownloadState": "importBlocked", "statusMessages": [{"messages": ["Import blocked due to issue C"]}], - } + }, ] # Test the different patterns and check if the right downloads are selected @pytest.mark.asyncio @pytest.mark.parametrize( - "patterns, expected_download_ids, removal_messages_expected", + ("patterns", "expected_download_ids", "removal_messages_expected"), [ - (["*"], ["1", "2", "3"], True), # Match everything, expect removal messages - (["Import failed*"], ["1", "2"], True), # Match "Import failed", expect removal messages - (["Import blocked*"], ["3"], True), # Match "Import blocked", expect removal messages - (["*due to issue A"], ["1"], True), # Match "due to issue A", expect removal messages - (["Import failed due to issue C"], [], False), # No match for "Import failed due to issue C", expect no removal messages + (["*"], ["1", "2", "3"], True), # Match everything, expect removal messages + ( + ["Import failed*"], + ["1", "2"], + True, + ), # Match "Import failed", expect removal messages + ( + ["Import blocked*"], + ["3"], + True, + ), # Match "Import blocked", expect removal messages + ( + ["*due to issue A"], + ["1"], + True, + ), # Match "due to issue A", expect removal messages + ( + ["Import failed due to issue C"], + [], + False, + ), # No match for "Import failed due to issue C", expect no removal messages ], ) -async def test_find_affected_items_with_patterns(queue_data, patterns, expected_download_ids, removal_messages_expected): +async def test_find_affected_items_with_patterns( + queue_data, patterns, expected_download_ids, removal_messages_expected +): + # Arrange - removal_job = RemoveFailedImports(arr=MagicMock(), settings=MagicMock(),job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveFailedImports, queue_data) # Mock the job settings for message patterns removal_job.job = MagicMock() removal_job.job.message_patterns = patterns - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 + # Act and Assert (Shared) + affected_items = await shared_test_affected_items(removal_job, expected_download_ids) - # Assert - assert isinstance(affected_items, list) - # Check if the correct downloadIds are in the affected items affected_download_ids = [item["downloadId"] for item in affected_items] # Assert the affected download IDs are as expected assert sorted(affected_download_ids) == sorted(expected_download_ids) - # Check if removal messages are expected and present for item in affected_items: if removal_messages_expected: - assert "removal_messages" in item, f"Expected removal messages for item {item['downloadId']}" - assert len(item["removal_messages"]) > 0, f"Expected non-empty removal messages for item {item['downloadId']}" + assert ( + "removal_messages" in item + ), f"Expected removal messages for item {item['downloadId']}" + assert ( + len(item["removal_messages"]) > 0 + ), f"Expected non-empty removal messages for item {item['downloadId']}" else: - assert "removal_messages" not in item, f"Did not expect removal messages for item {item['downloadId']}" \ No newline at end of file + assert ( + "removal_messages" not in item + ), f"Did not expect removal messages for item {item['downloadId']}" diff --git a/tests/jobs/test_remove_metadata_missing.py b/tests/jobs/test_remove_metadata_missing.py index 4c737ee..906fff6 100644 --- a/tests/jobs/test_remove_metadata_missing.py +++ b/tests/jobs/test_remove_metadata_missing.py @@ -1,56 +1,94 @@ import pytest -from unittest.mock import MagicMock + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_metadata_missing import RemoveMetadataMissing + + # Test to check if items with the specific error message are included in affected items with parameterized data @pytest.mark.asyncio @pytest.mark.parametrize( - "queue_data, expected_download_ids", + ("queue_data", "expected_download_ids"), [ ( [ - {"downloadId": "1", "status": "queued", "errorMessage": "qBittorrent is downloading metadata"}, # Valid item - {"downloadId": "2", "status": "completed", "errorMessage": "qBittorrent is downloading metadata"}, # Wrong status - {"downloadId": "3", "status": "queued", "errorMessage": "Some other error"} # Incorrect errorMessage + { + "downloadId": "1", + "status": "queued", + "errorMessage": "qBittorrent is downloading metadata", + }, # Valid item + { + "downloadId": "2", + "status": "completed", + "errorMessage": "qBittorrent is downloading metadata", + }, # Wrong status + { + "downloadId": "3", + "status": "queued", + "errorMessage": "Some other error", + }, # Incorrect errorMessage ], - ["1"] # Only the item with "queued" status and the correct errorMessage should be affected + [ + "1" + ], # Only the item with "queued" status and the correct errorMessage should be affected ), ( [ - {"downloadId": "1", "status": "queued", "errorMessage": "Some other error"}, # Incorrect errorMessage - {"downloadId": "2", "status": "completed", "errorMessage": "qBittorrent is downloading metadata"}, # Wrong status - {"downloadId": "3", "status": "queued", "errorMessage": "qBittorrent is downloading metadata"} # Correct item + { + "downloadId": "1", + "status": "queued", + "errorMessage": "Some other error", + }, # Incorrect errorMessage + { + "downloadId": "2", + "status": "completed", + "errorMessage": "qBittorrent is downloading metadata", + }, # Wrong status + { + "downloadId": "3", + "status": "queued", + "errorMessage": "qBittorrent is downloading metadata", + }, # Correct item ], - ["3"] # Only the item with "queued" status and the correct errorMessage should be affected + [ + "3" + ], # Only the item with "queued" status and the correct errorMessage should be affected ), ( [ - {"downloadId": "1", "status": "queued", "errorMessage": "qBittorrent is downloading metadata"}, # Valid item - {"downloadId": "2", "status": "queued", "errorMessage": "qBittorrent is downloading metadata"} # Another valid item + { + "downloadId": "1", + "status": "queued", + "errorMessage": "qBittorrent is downloading metadata", + }, # Valid item + { + "downloadId": "2", + "status": "queued", + "errorMessage": "qBittorrent is downloading metadata", + }, # Another valid item ], - ["1", "2"] # Both items match the condition + ["1", "2"], # Both items match the condition ), ( [ - {"downloadId": "1", "status": "completed", "errorMessage": "qBittorrent is downloading metadata"}, # Wrong status - {"downloadId": "2", "status": "queued", "errorMessage": "Some other error"} # Incorrect errorMessage + { + "downloadId": "1", + "status": "completed", + "errorMessage": "qBittorrent is downloading metadata", + }, # Wrong status + { + "downloadId": "2", + "status": "queued", + "errorMessage": "Some other error", + }, # Incorrect errorMessage ], - [] # No items match the condition - ) - ] + [], # No items match the condition + ), + ], ) async def test_find_affected_items(queue_data, expected_download_ids): # Arrange - removal_job = RemoveMetadataMissing(arr=MagicMock(), settings=MagicMock(),job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveMetadataMissing, queue_data) - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - assert isinstance(affected_items, list) - - # Assert that the affected items match the expected download IDs - affected_download_ids = [item["downloadId"] for item in affected_items] - assert sorted(affected_download_ids) == sorted(expected_download_ids), \ - f"Expected affected items with downloadIds {expected_download_ids}, got {affected_download_ids}" + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_missing_files.py b/tests/jobs/test_remove_missing_files.py index 06b293f..2964192 100644 --- a/tests/jobs/test_remove_missing_files.py +++ b/tests/jobs/test_remove_missing_files.py @@ -1,10 +1,12 @@ -from unittest.mock import MagicMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_missing_files import RemoveMissingFiles + @pytest.mark.asyncio @pytest.mark.parametrize( - "queue_data, expected_download_ids", + ("queue_data", "expected_download_ids"), [ ( [ # valid failed torrent (warning + matching errorMessage) @@ -12,13 +14,13 @@ from src.jobs.remove_missing_files import RemoveMissingFiles {"downloadId": "2", "status": "warning", "errorMessage": "The download is missing files"}, {"downloadId": "3", "status": "warning", "errorMessage": "qBittorrent is reporting missing files"}, ], - ["1", "2", "3"] + ["1", "2", "3"], ), ( [ # wrong status for errorMessage, should be ignored {"downloadId": "1", "status": "failed", "errorMessage": "The download is missing files"}, ], - [] + [], ), ( [ # valid "completed" with matching statusMessage @@ -26,18 +28,18 @@ from src.jobs.remove_missing_files import RemoveMissingFiles "downloadId": "1", "status": "completed", "statusMessages": [ - {"messages": ["No files found are eligible for import in /some/path"]} + {"messages": ["No files found are eligible for import in /some/path"]}, ], }, { "downloadId": "2", "status": "completed", "statusMessages": [ - {"messages": ["Everything looks good!"]} + {"messages": ["Everything looks good!"]}, ], }, ], - ["1"] + ["1"], ), ( [ # No statusMessages key or irrelevant messages @@ -45,10 +47,10 @@ from src.jobs.remove_missing_files import RemoveMissingFiles { "downloadId": "2", "status": "completed", - "statusMessages": [{"messages": ["Other message"]}] + "statusMessages": [{"messages": ["Other message"]}], }, ], - [] + [], ), ( [ # Mixed: one matching warning + one matching statusMessage @@ -56,24 +58,17 @@ from src.jobs.remove_missing_files import RemoveMissingFiles { "downloadId": "2", "status": "completed", - "statusMessages": [{"messages": ["No files found are eligible for import in foo"]}] + "statusMessages": [{"messages": ["No files found are eligible for import in foo"]}], }, {"downloadId": "3", "status": "completed"}, ], - ["1", "2"] + ["1", "2"], ), - ] + ], ) async def test_find_affected_items(queue_data, expected_download_ids): # Arrange - removal_job = RemoveMissingFiles(arr=MagicMock(), settings=MagicMock(),job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveMissingFiles, queue_data) - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - assert isinstance(affected_items, list) - affected_download_ids = [item["downloadId"] for item in affected_items] - assert sorted(affected_download_ids) == sorted(expected_download_ids), \ - f"Expected affected items with downloadIds {expected_download_ids}, got {affected_download_ids}" + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_orphans.py b/tests/jobs/test_remove_orphans.py index d80d7c7..1a02891 100644 --- a/tests/jobs/test_remove_orphans.py +++ b/tests/jobs/test_remove_orphans.py @@ -1,49 +1,47 @@ - -from unittest.mock import MagicMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_orphans import RemoveOrphans -@pytest.fixture(name="queue_data") -def fixture_queue_data(): - return [ - { - "downloadId": "AABBCC", - "id": 1, - "title": "My Series A - Season 1", - "size": 1000, - "sizeleft": 500, - "downloadClient": "qBittorrent", - "protocol": "torrent", - "status": "paused", - "trackedDownloadState": "downloading", - "statusMessages": [], - }, - { - "downloadId": "112233", - "id": 2, - "title": "My Series B - Season 1", - "size": 1000, - "sizeleft": 500, - "downloadClient": "qBittorrent", - "protocol": "torrent", - "status": "paused", - "trackedDownloadState": "downloading", - "statusMessages": [], - } - ] @pytest.mark.asyncio -async def test_find_affected_items_returns_queue(queue_data): - # Fix +@pytest.mark.parametrize( + ("queue_data", "expected_download_ids"), + [ + ( + [ + { + "downloadId": "1", + "id": 1, + "title": "My Series A - Season 1", + "size": 1000, + "sizeleft": 500, + "downloadClient": "qBittorrent", + "protocol": "torrent", + "status": "paused", + "trackedDownloadState": "downloading", + "statusMessages": [], + }, + { + "downloadId": "2", + "id": 2, + "title": "My Series B - Season 1", + "size": 1000, + "sizeleft": 500, + "downloadClient": "qBittorrent", + "protocol": "torrent", + "status": "paused", + "trackedDownloadState": "downloading", + "statusMessages": [], + }, + ], + ["1", "2"], + ), + ], +) +async def test_find_affected_items(queue_data, expected_download_ids): + # Arrange + removal_job = shared_fix_affected_items(RemoveOrphans, queue_data) - removal_job = RemoveOrphans(arr=MagicMock(), settings=MagicMock(),job_name="test") - removal_job.queue = queue_data - - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - assert isinstance(affected_items, list) - assert len(affected_items) == 2 - assert affected_items[0]["downloadId"] == "AABBCC" - assert affected_items[1]["downloadId"] == "112233" + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_slow.py b/tests/jobs/test_remove_slow.py index 00c7fd7..e963392 100644 --- a/tests/jobs/test_remove_slow.py +++ b/tests/jobs/test_remove_slow.py @@ -1,11 +1,13 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_slow import RemoveSlow -@pytest.mark.asyncio +@pytest.mark.asynciox @pytest.mark.parametrize( - "item, expected_result", + ("item", "expected_result"), [ ( # Valid: has downloadId, size, sizeleft, and status = "downloading" @@ -57,11 +59,11 @@ from src.jobs.remove_slow import RemoveSlow ) async def test_is_valid_item(item, expected_result): # Arrange - removal_job = RemoveSlow(arr=MagicMock(), settings=MagicMock(),job_name="test") + removal_job = shared_fix_affected_items(RemoveSlow) # Act result = removal_job._is_valid_item(item) # pylint: disable=W0212 - + # Assert assert result == expected_result @@ -114,43 +116,35 @@ def fixture_queue_data(): ] -@pytest.fixture(name="arr") -def fixture_arr(): - mock = MagicMock() - mock.tracker.download_progress = AsyncMock() - return mock - - @pytest.mark.asyncio @pytest.mark.parametrize( - "min_speed, expected_ids", + ("min_speed", "expected_download_ids"), [ (0, []), # No min download speed; all torrents pass (500, ["stuck"]), # Only stuck and slow are included (1000, ["stuck", "slow"]), # Same as above (10000, ["stuck", "slow", "medium"]), # Only stuck and slow are below 5.0 - (1000000, ["stuck", "slow", "medium", "fast"]), # Fast torrent included (but not importing) + (1000000, ["stuck", "slow", "medium", "fast"]), # Fast torrent included (but not importing) ], ) async def test_find_affected_items_with_varied_speeds( - queue_data, min_speed, expected_ids, arr + queue_data, min_speed, expected_download_ids, ): # Arrange - removal_job = RemoveSlow(arr=MagicMock(), settings=MagicMock(),job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveSlow, queue_data) - # Set up job and timer + # Job-specific arrangements removal_job.job = MagicMock() removal_job.job.min_speed = min_speed removal_job.settings = MagicMock() - removal_job.settings.general.timer = 1 # 1 minute for speed calculation - removal_job.arr = arr # Inject the mocked arr object - removal_job._is_valid_item = MagicMock( return_value=True ) # Mock the _is_valid_item method to always return True # pylint: disable=W0212 - + removal_job.settings.general.timer = 1 + + removal_job._is_valid_item = MagicMock(return_value=True) # Mock the _is_valid_item method to always return True # pylint: disable=W0212 + # Inject size and sizeleft into each item in the queue for item in queue_data: item["size"] = item["total_size"] * 1000000 # Inject total size as 'size' - item["sizeleft"] = ( item["size"] - item["progress_now"] * 1000000 ) # Calculate sizeleft + item["sizeleft"] = item["size"] - item["progress_now"] * 1000000 # Calculate sizeleft item["status"] = "downloading" item["title"] = item["downloadId"] @@ -160,14 +154,5 @@ async def test_find_affected_items_with_varied_speeds( for item in queue_data } - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - affected_ids = [item["downloadId"] for item in affected_items] - - # Assert that the affected cases match the expected ones - assert sorted(affected_ids) == sorted(expected_ids) - - # Ensure 'importing' and 'usenet' are never flagged for removal - assert "importing" not in affected_ids - assert "usenet" not in affected_ids \ No newline at end of file + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_stalled.py b/tests/jobs/test_remove_stalled.py index 0022854..5c09b2f 100644 --- a/tests/jobs/test_remove_stalled.py +++ b/tests/jobs/test_remove_stalled.py @@ -1,56 +1,49 @@ -from unittest.mock import AsyncMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_stalled import RemoveStalled + # Test to check if items with the specific error message are included in affected items with parameterized data @pytest.mark.asyncio @pytest.mark.parametrize( - "queue_data, expected_download_ids", + ("queue_data", "expected_download_ids"), [ ( [ {"downloadId": "1", "status": "warning", "errorMessage": "The download is stalled with no connections"}, # Valid item {"downloadId": "2", "status": "completed", "errorMessage": "The download is stalled with no connections"}, # Wrong status - {"downloadId": "3", "status": "warning", "errorMessage": "Some other error"} # Incorrect errorMessage + {"downloadId": "3", "status": "warning", "errorMessage": "Some other error"}, # Incorrect errorMessage ], - ["1"] # Only the item with "warning" status and the correct errorMessage should be affected + ["1"], # Only the item with "warning" status and the correct errorMessage should be affected ), ( [ {"downloadId": "1", "status": "warning", "errorMessage": "Some other error"}, # Incorrect errorMessage {"downloadId": "2", "status": "completed", "errorMessage": "The download is stalled with no connections"}, # Wrong status - {"downloadId": "3", "status": "warning", "errorMessage": "The download is stalled with no connections"} # Correct item + {"downloadId": "3", "status": "warning", "errorMessage": "The download is stalled with no connections"}, # Correct item ], - ["3"] # Only the item with "warning" status and the correct errorMessage should be affected + ["3"], # Only the item with "warning" status and the correct errorMessage should be affected ), ( [ {"downloadId": "1", "status": "warning", "errorMessage": "The download is stalled with no connections"}, # Valid item - {"downloadId": "2", "status": "warning", "errorMessage": "The download is stalled with no connections"} # Another valid item + {"downloadId": "2", "status": "warning", "errorMessage": "The download is stalled with no connections"}, # Another valid item ], - ["1", "2"] # Both items match the condition + ["1", "2"], # Both items match the condition ), ( [ {"downloadId": "1", "status": "completed", "errorMessage": "The download is stalled with no connections"}, # Wrong status - {"downloadId": "2", "status": "warning", "errorMessage": "Some other error"} # Incorrect errorMessage + {"downloadId": "2", "status": "warning", "errorMessage": "Some other error"}, # Incorrect errorMessage ], - [] # No items match the condition - ) - ] + [], # No items match the condition + ), + ], ) async def test_find_affected_items(queue_data, expected_download_ids): # Arrange - removal_job = RemoveStalled(arr=AsyncMock(), settings=AsyncMock(),job_name="test") - removal_job.queue = queue_data + removal_job = shared_fix_affected_items(RemoveStalled, queue_data) - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - assert isinstance(affected_items, list) - - # Assert that the affected items match the expected download IDs - affected_download_ids = [item["downloadId"] for item in affected_items] - assert sorted(affected_download_ids) == sorted(expected_download_ids), \ - f"Expected affected items with downloadIds {expected_download_ids}, got {affected_download_ids}" + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_remove_unmonitored.py b/tests/jobs/test_remove_unmonitored.py index 7bc3610..6ca6c9b 100644 --- a/tests/jobs/test_remove_unmonitored.py +++ b/tests/jobs/test_remove_unmonitored.py @@ -1,55 +1,58 @@ -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock import pytest + +from tests.jobs.utils import shared_fix_affected_items, shared_test_affected_items from src.jobs.remove_unmonitored import RemoveUnmonitored + @pytest.mark.asyncio @pytest.mark.parametrize( - "queue_data, monitored_ids, expected_download_ids", + ("queue_data", "monitored_ids", "expected_download_ids"), [ # All items monitored -> no affected items ( [ {"downloadId": "1", "detail_item_id": 101}, - {"downloadId": "2", "detail_item_id": 102} + {"downloadId": "2", "detail_item_id": 102}, ], {101: True, 102: True}, - [] + [], ), # All items unmonitored -> all affected ( [ {"downloadId": "1", "detail_item_id": 101}, - {"downloadId": "2", "detail_item_id": 102} + {"downloadId": "2", "detail_item_id": 102}, ], {101: False, 102: False}, - ["1", "2"] + ["1", "2"], ), # One monitored, one not ( [ {"downloadId": "1", "detail_item_id": 101}, - {"downloadId": "2", "detail_item_id": 102} + {"downloadId": "2", "detail_item_id": 102}, ], {101: True, 102: False}, - ["2"] + ["2"], ), # Shared downloadId, only one monitored -> not affected ( [ {"downloadId": "1", "detail_item_id": 101}, - {"downloadId": "1", "detail_item_id": 102} + {"downloadId": "1", "detail_item_id": 102}, ], {101: False, 102: True}, - [] + [], ), # Shared downloadId, none monitored -> affected ( [ {"downloadId": "1", "detail_item_id": 101}, - {"downloadId": "1", "detail_item_id": 102} + {"downloadId": "1", "detail_item_id": 102}, ], {101: False, 102: False}, - ["1", "1"] + ["1", "1"], ), # One monitored, one not, one not matched yet ( @@ -65,16 +68,7 @@ from src.jobs.remove_unmonitored import RemoveUnmonitored ) async def test_find_affected_items(queue_data, monitored_ids, expected_download_ids): # Arrange - arr = MagicMock() - arr.is_monitored = AsyncMock(side_effect=lambda id_: monitored_ids[id_]) - - removal_job = RemoveUnmonitored(arr=arr, settings=MagicMock(), job_name="test") - removal_job.queue = queue_data - - # Act - affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 - - # Assert - affected_download_ids = [item["downloadId"] for item in affected_items] - assert affected_download_ids == expected_download_ids, \ - f"Expected downloadIds {expected_download_ids}, got {affected_download_ids}" \ No newline at end of file + removal_job = shared_fix_affected_items(RemoveUnmonitored, queue_data) + removal_job.arr.is_monitored = AsyncMock(side_effect=lambda id_: monitored_ids[id_]) + # Act and Assert + await shared_test_affected_items(removal_job, expected_download_ids) diff --git a/tests/jobs/test_strikes_handler.py b/tests/jobs/test_strikes_handler.py index d8a062a..787ba65 100644 --- a/tests/jobs/test_strikes_handler.py +++ b/tests/jobs/test_strikes_handler.py @@ -1,9 +1,12 @@ from unittest.mock import MagicMock + import pytest + from src.jobs.strikes_handler import StrikesHandler + @pytest.mark.parametrize( - "current_hashes, expected_remaining_in_tracker", + ("current_hashes", "expected_remaining_in_tracker"), [ ([], []), # nothing active → all removed (["HASH1", "HASH2"], ["HASH1", "HASH2"]), # both active → none removed @@ -11,14 +14,14 @@ from src.jobs.strikes_handler import StrikesHandler ], ) def test_recover_downloads(current_hashes, expected_remaining_in_tracker): - """Tests if tracker correctly removes items (if recovered) and adds new ones""" + """Test if tracker correctly removes items (if recovered) and adds new ones.""" # Fix tracker = MagicMock() tracker.defective = { "remove_stalled": { "HASH1": {"title": "Movie-with-one-strike", "strikes": 1}, "HASH2": {"title": "Movie-with-three-strikes", "strikes": 3}, - } + }, } arr = MagicMock() arr.tracker = tracker @@ -35,12 +38,12 @@ def test_recover_downloads(current_hashes, expected_remaining_in_tracker): # ---------- Test ---------- @pytest.mark.parametrize( - "strikes_before_increment, max_strikes, expected_in_affected_downloads", + ("strikes_before_increment", "max_strikes", "expected_in_affected_downloads"), [ (1, 3, False), # Below limit → should not be affected (2, 3, False), # Below limit → should not be affected (3, 3, True), # At limit, will be pushed over limit → should not be affected - (4, 3, True), # Over limit → should be affected + (4, 3, True), # Over limit → should be affected ], ) def test_apply_strikes_and_filter(strikes_before_increment, max_strikes, expected_in_affected_downloads): @@ -54,7 +57,7 @@ def test_apply_strikes_and_filter(strikes_before_increment, max_strikes, expecte handler = StrikesHandler(job_name=job_name, arr=arr, max_strikes=max_strikes) affected_downloads = { - "HASH1": [{"title": "dummy"}] + "HASH1": {"title": "dummy"}, } result = handler._apply_strikes_and_filter(affected_downloads) # pylint: disable=W0212 diff --git a/tests/jobs/utils.py b/tests/jobs/utils.py new file mode 100644 index 0000000..6614a09 --- /dev/null +++ b/tests/jobs/utils.py @@ -0,0 +1,22 @@ +from unittest.mock import MagicMock + +def shared_fix_affected_items(removal_class, queue_data=None): + # Arrange + removal_job = removal_class(arr=MagicMock(), settings=MagicMock(),job_name="test") + if queue_data: + removal_job.queue = queue_data + return removal_job + +async def shared_test_affected_items(removal_job, expected_download_ids): + # Act + affected_items = await removal_job._find_affected_items() # pylint: disable=W0212 + + # Assert + assert isinstance(affected_items, list) + + # Assert that the affected items match the expected download IDs + affected_download_ids = [item["downloadId"] for item in affected_items] + assert sorted(affected_download_ids) == sorted(expected_download_ids), \ + f"Expected affected items with downloadIds {expected_download_ids}, got {affected_download_ids}" + + return affected_items diff --git a/tests/settings/__init__.py b/tests/settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/settings/test__user_config_from_env.py b/tests/settings/test__user_config_from_env.py index 57d443c..a3d3381 100644 --- a/tests/settings/test__user_config_from_env.py +++ b/tests/settings/test__user_config_from_env.py @@ -1,32 +1,35 @@ +"""Test loading the user configuration from environment variables.""" import os import textwrap +from unittest.mock import patch + import pytest import yaml -from unittest.mock import patch + from src.settings._user_config import _load_from_env # ---- Pytest Fixtures ---- # Pre-define multiline YAML snippets with dedent and strip for clarity # Single values as plain strings (not YAML block strings) -log_level_value = "VERBOSE" -timer_value = "10" -ssl_verification_value = "true" +LOG_LEVEL_VALUE = "VERBOSE" +TIMER_VALUE = "10" +SSL_VERIFICATION_VALUE = "true" # List -ignored_download_clients_yaml = textwrap.dedent(""" +ignored_download_clients_yaml = textwrap.dedent(""" - emulerr - napster """).strip() # Job: No settings -remove_bad_files_yaml = "" # empty string represents flag enabled with no config +remove_bad_files_yaml = "" # pylint: disable=C0103; empty string represents flag enabled with no config -# Job: One Setting +# Job: One Setting remove_slow_yaml = textwrap.dedent(""" - max_strikes: 3 """).strip() -# Job: Multiple Setting +# Job: Multiple Setting remove_stalled_yaml = textwrap.dedent(""" - min_speed: 100 - max_strikes: 3 @@ -55,12 +58,13 @@ qbit_yaml = textwrap.dedent(""" password: "qbit_password1" """).strip() + @pytest.fixture(name="env_vars") def fixture_env_vars(): env = { - "LOG_LEVEL": log_level_value, - "TIMER": timer_value, - "SSL_VERIFICATION": ssl_verification_value, + "LOG_LEVEL": LOG_LEVEL_VALUE, + "TIMER": TIMER_VALUE, + "SSL_VERIFICATION": SSL_VERIFICATION_VALUE, "IGNORED_DOWNLOAD_CLIENTS": ignored_download_clients_yaml, "REMOVE_BAD_FILES": remove_bad_files_yaml, "REMOVE_SLOW": remove_slow_yaml, @@ -82,9 +86,10 @@ radarr_expected = yaml.safe_load(radarr_yaml) sonarr_expected = yaml.safe_load(sonarr_yaml) qbit_expected = yaml.safe_load(qbit_yaml) -@pytest.mark.parametrize("section,key,expected", [ - ("general", "log_level", log_level_value), - ("general", "timer", int(timer_value)), + +@pytest.mark.parametrize(("section", "key", "expected"), [ + ("general", "log_level", LOG_LEVEL_VALUE), + ("general", "timer", int(TIMER_VALUE)), ("general", "ssl_verification", True), ("general", "ignored_download_clients", remove_ignored_download_clients_expected), ("jobs", "remove_bad_files", remove_bad_files_expected), @@ -94,16 +99,14 @@ qbit_expected = yaml.safe_load(qbit_yaml) ("instances", "sonarr", sonarr_expected), ("download_clients", "qbittorrent", qbit_expected), ]) -def test_env_loading_parametrized(env_vars, section, key, expected): # pylint: disable=unused-argument +def test_env_loading_parametrized(env_vars, section, key, expected): # pylint: disable=unused-argument # noqa: ARG001 config = _load_from_env() assert section in config assert key in config[section] value = config[section][key] - + if isinstance(expected, list): # Compare as lists assert value == expected else: assert value == expected - - diff --git a/tests/utils/test_queue_manager.py b/tests/utils/test_queue_manager.py index e90b0d0..2bd24bc 100644 --- a/tests/utils/test_queue_manager.py +++ b/tests/utils/test_queue_manager.py @@ -1,12 +1,12 @@ +from unittest.mock import Mock import pytest -# Assuming your method is part of a class called QueueManager from src.utils.queue_manager import QueueManager -from unittest.mock import Mock + # ---------- Fixtures ---------- @pytest.fixture(name="mock_queue_manager") -def mock_queue_manager(): +def fixture_mock_queue_manager(): mock_arr = Mock() mock_settings = Mock() return QueueManager(arr=mock_arr, settings=mock_settings)