Skip to content

Commit 34fcff4

Browse files
committed
Periodic Rescans Added (New Feature)
1 parent c7e5c0e commit 34fcff4

21 files changed

Lines changed: 471 additions & 252 deletions

.github/workflows/build.yml

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,28 +35,10 @@ jobs:
3535
echo "valid_branch_name=true" >> $GITHUB_OUTPUT
3636
fi
3737
38-
lint:
38+
unit-tests:
3939
needs: validate-branch-name
4040
if: needs.validate-branch-name.outputs.valid_branch_name == 'true'
4141
runs-on: ubuntu-latest
42-
steps:
43-
- uses: actions/checkout@v4
44-
- name: Set up Python
45-
uses: actions/setup-python@v4
46-
with:
47-
python-version: '3.10.13'
48-
- name: Install linting dependencies
49-
run: |
50-
python -m pip install --upgrade pip
51-
pip install pylint
52-
- name: Run lint checks
53-
run: |
54-
echo "here we'll lint"
55-
# pylint my_project
56-
57-
unit-tests:
58-
needs: lint
59-
runs-on: ubuntu-latest
6042
steps:
6143
- uses: actions/checkout@v4
6244
- name: Set up Python

.pre-commit-config.yaml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,8 @@ repos:
33
hooks:
44
- id: black
55
name: black
6-
entry: venv/bin/black
6+
entry: venv/bin/black --config pyproject.toml
77
language: system
88
types: [python]
99

1010

11-

.pytest.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[pytest]
2-
log_cli = true
2+
# log_cli = true
33
addopts = -q --tb=short -s
44
log_cli_level = INFO
55
log_cli_format = %(asctime)s - %(levelname)s - %(name)s - %(message)s

README.md

Lines changed: 80 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -55,47 +55,66 @@ services:
5555
container_name: decluttarr
5656
restart: always
5757
environment:
58-
- TZ=Europe/Zurich
59-
- PUID=1000
60-
- PGID=1000
61-
## General
62-
- LOG_LEVEL=INFO
63-
#- TEST_RUN=True
64-
#- SSL_VERIFICATION=False
65-
## Features
66-
- REMOVE_TIMER=10
67-
- REMOVE_FAILED=True
68-
- REMOVE_FAILED_IMPORTS=True
69-
- REMOVE_METADATA_MISSING=True
70-
- REMOVE_MISSING_FILES=True
71-
- REMOVE_ORPHANS=True
72-
- REMOVE_SLOW=True
73-
- REMOVE_STALLED=True
74-
- REMOVE_UNMONITORED=True
75-
- MIN_DOWNLOAD_SPEED=100
76-
- PERMITTED_ATTEMPTS=3
77-
- NO_STALLED_REMOVAL_QBIT_TAG=Don't Kill
78-
- IGNORE_PRIVATE_TRACKERS=True
79-
- FAILED_IMPORT_MESSAGE_PATTERNS=["Not an upgrade for existing", "Not a Custom Format upgrade for existing"]
80-
## Radarr
81-
- RADARR_URL=http://radarr:7878
82-
- RADARR_KEY=$RADARR_API_KEY
83-
## Sonarr
84-
- SONARR_URL=http://sonarr:8989
85-
- SONARR_KEY=$SONARR_API_KEY
86-
## Lidarr
87-
- LIDARR_URL=http://lidarr:8686
88-
- LIDARR_KEY=$LIDARR_API_KEY
89-
## Readarr
90-
- READARR_URL=http://readarr:8787
91-
- READARR_KEY=$READARR_API_KEY
92-
## Whisparr
93-
- WHISPARR_URL=http://whisparr:6969
94-
- WHISPARR_KEY=$WHISPARR_API_KEY
95-
## qBittorrent
96-
- QBITTORRENT_URL=http://qbittorrent:8080
97-
#- QBITTORRENT_USERNAME=Your name
98-
#- QBITTORRENT_PASSWORD=Your password
58+
TZ=Europe/Zurich
59+
PUID=1000
60+
PGID=1000
61+
62+
## General
63+
# TEST_RUN=True
64+
# SSL_VERIFICATION=False
65+
LOG_LEVEL: INFO
66+
67+
## Features
68+
REMOVE_TIMER: 10
69+
REMOVE_FAILED: True
70+
REMOVE_FAILED_IMPORTS: True
71+
REMOVE_METADATA_MISSING: True
72+
REMOVE_MISSING_FILES: True
73+
REMOVE_ORPHANS: True
74+
REMOVE_SLOW: True
75+
REMOVE_STALLED: True
76+
REMOVE_UNMONITORED: True
77+
RUN_PERIODIC_RESCANS: '
78+
{
79+
"SONARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7},
80+
"RADARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}
81+
}'
82+
83+
# Feature Settings
84+
PERMITTED_ATTEMPTS: 3
85+
NO_STALLED_REMOVAL_QBIT_TAG: Don't Kill
86+
REMOVE_SLOW: True
87+
MIN_DOWNLOAD_SPEED: 100
88+
FAILED_IMPORT_MESSAGE_PATTERNS: '
89+
[
90+
"Not a Custom Format upgrade for existing",
91+
"Not an upgrade for existing"
92+
]'
93+
94+
## Radarr
95+
RADARR_URL: http://radarr:7878
96+
RADARR_KEY: $RADARR_API_KEY
97+
98+
## Sonarr
99+
SONARR_URL: http://sonarr:8989
100+
SONARR_KEY: $SONARR_API_KEY
101+
102+
## Lidarr
103+
LIDARR_URL=http://lidarr:8686
104+
LIDARR_KEY=$LIDARR_API_KEY
105+
106+
## Readarr
107+
READARR_URL=http://readarr:8787
108+
READARR_KEY=$READARR_API_KEY
109+
110+
## Whisparr
111+
WHISPARR_URL=http://whisparr:6969
112+
WHISPARR_KEY=$WHISPARR_API_KEY
113+
114+
## qBitorrent
115+
QBITTORRENT_URL: http://qbittorrent:8080
116+
# QBITTORRENT_USERNAME=Your name
117+
# QBITTORRENT_PASSWORD=Your password
99118
```
100119
3) Run `docker-compose up -d` in the directory where the file is located to create the docker container
101120
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
@@ -212,6 +231,26 @@ Steers which type of cleaning is applied to the downloads queue
212231
- Permissible Values: True, False
213232
- Is Mandatory: No (Defaults to False)
214233

234+
**RUN_PERIODIC_RESCANS**
235+
- Steers whether searches are automatically triggered for items that are missing or have not yet met the cutoff
236+
- Note: Only supports Radarr/Sonarr currently (Lidarr depending on: https://github.com/Lidarr/Lidarr/pull/5084 / Readarr Depending on: https://github.com/Readarr/Readarr/pull/3724)
237+
- Type: Dictionaire
238+
- Is Mandatory: No (Defaults to no searches being triggered automatically)
239+
- "SONARR"/"RADARR" turns on the automatic searches for the respective instances
240+
- "MISSING"/"CUTOFF_UNMET" turns on the automatic search for those wanted items (defaults to True)
241+
- "MAX_CONCURRENT_SCANS" specifies the maximum number of items to be searched in each scan. This value dictates how many items are processed per search operation, which occurs according to the interval set by the REMOVE_TIMER.
242+
- Note: The limit is per wanted list. Thus if both Radarr & Sonarr are set up for automatic searches, both for missing and cutoff unmet items, the actual count may be four times the MAX_CONCURRENT_SCANS
243+
- "MIN_DAYS_BEFORE_RESCAN" steers the days that need to pass before an item is considered again for a scan
244+
- Note: RUN_PERIODIC_RESCANS will always search those items that haven been searched for longest
245+
246+
```
247+
RUN_PERIODIC_RESCANS: '
248+
{
249+
"SONARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7},
250+
"RADARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}
251+
}'
252+
```
253+
215254
**MIN_DOWNLOAD_SPEED**
216255
- Sets the minimum download speed for active downloads
217256
- If the increase in the downloaded file size of a download is less than this value between two consecutive checks, the download is considered slow and is removed if happening more ofthen than the permitted attempts

config/config.conf-Example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ REMOVE_ORPHANS = True
1212
REMOVE_SLOW = True
1313
REMOVE_STALLED = True
1414
REMOVE_UNMONITORED = True
15+
RUN_PERIODIC_RESCANS = {"SONARR": {"MISSING": true, CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}, "RADARR": {"MISSING": true, "CUTOFF_UNMET": true, "MAX_CONCURRENT_SCANS": 3, "MIN_DAYS_BEFORE_RESCAN": 7}}
16+
17+
[feature_settings]
1518
MIN_DOWNLOAD_SPEED = 100
1619
PERMITTED_ATTEMPTS = 3
1720
NO_STALLED_REMOVAL_QBIT_TAG = Don't Kill

config/definitions.py

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,20 @@
1212
REMOVE_FAILED = get_config_value('REMOVE_FAILED', 'features', False, bool, False)
1313
REMOVE_FAILED_IMPORTS = get_config_value('REMOVE_FAILED_IMPORTS' , 'features', False, bool, False)
1414
REMOVE_METADATA_MISSING = get_config_value('REMOVE_METADATA_MISSING', 'features', False, bool, False)
15-
REMOVE_MISSING_FILES = get_config_value('REMOVE_MISSING_FILES' , 'features', False, bool, False)
16-
REMOVE_NO_FORMAT_UPGRADE = get_config_value('REMOVE_NO_FORMAT_UPGRADE' , 'features', False, bool, False) # OUTDATED - WILL RETURN WARNING
17-
REMOVE_ORPHANS = get_config_value('REMOVE_ORPHANS' , 'features', False, bool, False)
18-
REMOVE_SLOW = get_config_value('REMOVE_SLOW' , 'features', False, bool, False)
15+
REMOVE_MISSING_FILES = get_config_value('REMOVE_MISSING_FILES', 'features', False, bool, False)
16+
REMOVE_NO_FORMAT_UPGRADE = get_config_value('REMOVE_NO_FORMAT_UPGRADE', 'features', False, bool, False) # OUTDATED - WILL RETURN WARNING
17+
REMOVE_ORPHANS = get_config_value('REMOVE_ORPHANS', 'features', False, bool, False)
18+
REMOVE_SLOW = get_config_value('REMOVE_SLOW', 'features', False, bool, False)
1919
REMOVE_STALLED = get_config_value('REMOVE_STALLED', 'features', False, bool, False)
20-
REMOVE_UNMONITORED = get_config_value('REMOVE_UNMONITORED' , 'features', False, bool, False)
21-
MIN_DOWNLOAD_SPEED = get_config_value('MIN_DOWNLOAD_SPEED', 'features', False, int, 0)
22-
PERMITTED_ATTEMPTS = get_config_value('PERMITTED_ATTEMPTS', 'features', False, int, 3)
23-
NO_STALLED_REMOVAL_QBIT_TAG = get_config_value('NO_STALLED_REMOVAL_QBIT_TAG', 'features', False, str, 'Don\'t Kill')
24-
IGNORE_PRIVATE_TRACKERS = get_config_value('IGNORE_PRIVATE_TRACKERS', 'features', False, bool, True)
25-
FAILED_IMPORT_MESSAGE_PATTERNS = get_config_value('FAILED_IMPORT_MESSAGE_PATTERNS','features', False, list, [])
20+
REMOVE_UNMONITORED = get_config_value('REMOVE_UNMONITORED', 'features', False, bool, False)
21+
RUN_PERIODIC_RESCANS = get_config_value('RUN_PERIODIC_RESCANS', 'features', False, dict, {})
22+
23+
# Feature Settings
24+
MIN_DOWNLOAD_SPEED = get_config_value('MIN_DOWNLOAD_SPEED', 'feature_settings', False, int, 0)
25+
PERMITTED_ATTEMPTS = get_config_value('PERMITTED_ATTEMPTS', 'feature_settings', False, int, 3)
26+
NO_STALLED_REMOVAL_QBIT_TAG = get_config_value('NO_STALLED_REMOVAL_QBIT_TAG', 'feature_settings', False, str, 'Don\'t Kill')
27+
IGNORE_PRIVATE_TRACKERS = get_config_value('IGNORE_PRIVATE_TRACKERS', 'feature_settings', False, bool, True)
28+
FAILED_IMPORT_MESSAGE_PATTERNS = get_config_value('FAILED_IMPORT_MESSAGE_PATTERNS','feature_settings', False, list, [])
2629

2730
# Radarr
2831
RADARR_URL = get_config_value('RADARR_URL', 'radarr', False, str)
@@ -60,6 +63,41 @@
6063
print(f'[ ERROR ]: No Radarr/Sonarr/Lidarr/Readarr/Whisparr URLs specified (nothing to monitor)')
6164
exit()
6265

66+
67+
#### Validate rescan settings
68+
PERIODIC_RESCANS = get_config_value("PERIODIC_RESCANS", "features", False, dict, {})
69+
70+
rescan_supported_apps = ["SONARR", "RADARR"]
71+
rescan_default_values = {
72+
"MISSING": (True, bool),
73+
"CUTOFF_UNMET": (True, bool),
74+
"MAX_CONCURRENT_SCANS": (3, int),
75+
"MIN_DAYS_BEFORE_RESCAN": (7, int),
76+
}
77+
78+
79+
# Remove rescan apps that are not supported
80+
for key in list(RUN_PERIODIC_RESCANS.keys()):
81+
if key not in rescan_supported_apps:
82+
print(f"[ WARNING ]: Removed '{key}' from RUN_PERIODIC_RESCANS since only {rescan_supported_apps} are supported.")
83+
RUN_PERIODIC_RESCANS.pop(key)
84+
85+
# Ensure SONARR and RADARR have the required parameters with default values if they are present
86+
for app in rescan_supported_apps:
87+
if app in RUN_PERIODIC_RESCANS:
88+
for param, (default, expected_type) in rescan_default_values.items():
89+
if param not in RUN_PERIODIC_RESCANS[app]:
90+
print(f"[ INFO ]: Adding missing parameter '{param}' to '{app}' with default value '{default}'.")
91+
RUN_PERIODIC_RESCANS[app][param] = default
92+
else:
93+
# Check the type and correct if necessary
94+
current_value = RUN_PERIODIC_RESCANS[app][param]
95+
if not isinstance(current_value, expected_type):
96+
print(
97+
f"[ INFO ]: Parameter '{param}' for '{app}' must be of type {expected_type.__name__} and found value '{current_value}' (type '{type(current_value).__name__}'). Defaulting to '{default}'."
98+
)
99+
RUN_PERIODIC_RESCANS[app][param] = default
100+
63101
########### Enrich setting variables
64102
if RADARR_URL: RADARR_URL = RADARR_URL.rstrip('/') + '/api/v3'
65103
if SONARR_URL: SONARR_URL = SONARR_URL.rstrip('/') + '/api/v3'
@@ -68,8 +106,14 @@
68106
if WHISPARR_URL: WHISPARR_URL = WHISPARR_URL.rstrip('/') + '/api/v3'
69107
if QBITTORRENT_URL: QBITTORRENT_URL = QBITTORRENT_URL.rstrip('/') + '/api/v2'
70108

71-
RADARR_MIN_VERSION = '5.3.6.8608'
72-
SONARR_MIN_VERSION = '4.0.1.1131'
109+
110+
RADARR_MIN_VERSION = "5.3.6.8608"
111+
if "RADARR" in PERIODIC_RESCANS:
112+
RADARR_MIN_VERSION = "5.10.3.9171"
113+
114+
SONARR_MIN_VERSION = "4.0.1.1131"
115+
if "SONARR" in PERIODIC_RESCANS:
116+
SONARR_MIN_VERSION = "4.0.9.2332"
73117
LIDARR_MIN_VERSION = None
74118
READARR_MIN_VERSION = None
75119
WHISPARR_MIN_VERSION = '2.0.0.548'
@@ -82,4 +126,3 @@
82126
for var_name in dir():
83127
if var_name.isupper():
84128
settingsDict[var_name] = locals()[var_name]
85-

config/parser.py

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,14 @@ def config_section_map(section):
2222
options = config.options(section)
2323
for option in options:
2424
try:
25-
dict1[option] = config.get(section, option)
26-
except:
27-
print("exception on %s!" % option)
25+
value = config.get(section, option)
26+
# Attempt to parse JSON for dictionary-like values
27+
try:
28+
dict1[option] = json.loads(value)
29+
except json.JSONDecodeError:
30+
dict1[option] = value
31+
except Exception as e:
32+
print(f"Exception on {option}: {e}")
2833
dict1[option] = None
2934
return dict1
3035

@@ -38,44 +43,33 @@ def get_config_value(key, config_section, is_mandatory, datatype, default_value=
3843
if IS_IN_DOCKER:
3944
config_value = os.environ.get(key)
4045
if config_value is not None:
41-
# print(f'The value retrieved for [{config_section}]: {key} is "{config_value}"')
4246
config_value = config_value
43-
# return config_value
4447
elif is_mandatory:
4548
print(f"[ ERROR ]: Variable not specified in Docker environment: {key}")
4649
sys.exit(0)
4750
else:
48-
# return default_value
49-
# print(f'The default value used for [{config_section}]: {key} is "{default_value}" (data type: {type(default_value).__name__})')
5051
config_value = default_value
51-
5252
else:
5353
try:
5454
config_value = config_section_map(config_section).get(key)
5555
except configparser.NoSectionError:
5656
config_value = None
5757
if config_value is not None:
58-
# print(f'The value retrieved for [{config_section}]: {key} is "{config_value}"')
5958
config_value = config_value
60-
# return config_value
6159
elif is_mandatory:
6260
print(
6361
f"[ ERROR ]: Mandatory variable not specified in config file, section [{config_section}]: {key} (data type: {datatype.__name__})"
6462
)
6563
sys.exit(0)
6664
else:
67-
# return default_value
68-
# print(f'The default value used for [{config_section}]: {key} is "{default_value}" (data type: {type(default_value).__name__})')
6965
config_value = default_value
7066

7167
# Apply data type
7268
try:
7369
if datatype == bool:
7470
config_value = eval(str(config_value).capitalize())
75-
elif datatype == list:
76-
if (
77-
type(config_value) != list
78-
): # Default value is already a list, doesn't need to be pushed through json.loads
71+
elif datatype == list or datatype == dict:
72+
if not isinstance(config_value, datatype):
7973
config_value = json.loads(config_value)
8074
elif config_value is not None:
8175
config_value = cast(config_value, datatype)

0 commit comments

Comments
 (0)