qBitrr2 5.8.6__py3-none-any.whl → 5.8.8__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- qBitrr/arss.py +61 -0
- qBitrr/bundled_data.py +2 -2
- qBitrr/config_version.py +1 -1
- qBitrr/database.py +29 -1
- qBitrr/gen_config.py +160 -0
- qBitrr/main.py +229 -0
- qBitrr/qbit_category_manager.py +293 -0
- qBitrr/static/assets/ArrView.js +1 -1
- qBitrr/static/assets/ArrView.js.map +1 -1
- qBitrr/static/assets/ConfigView.js +5 -5
- qBitrr/static/assets/ConfigView.js.map +1 -1
- qBitrr/static/assets/LogsView.js +9 -9
- qBitrr/static/assets/LogsView.js.map +1 -1
- qBitrr/static/assets/ProcessesView.js +1 -1
- qBitrr/static/assets/ProcessesView.js.map +1 -1
- qBitrr/static/assets/QbitCategoriesView.js +2 -0
- qBitrr/static/assets/QbitCategoriesView.js.map +1 -0
- qBitrr/static/assets/StableTable.js +2 -0
- qBitrr/static/assets/StableTable.js.map +1 -0
- qBitrr/static/assets/app.css +1 -1
- qBitrr/static/assets/app.js +23 -10
- qBitrr/static/assets/app.js.map +1 -1
- qBitrr/static/assets/table.js +1 -1
- qBitrr/static/assets/vendor.js +1 -1
- qBitrr/static/assets/vendor.js.map +1 -1
- qBitrr/webui.py +208 -2
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/METADATA +3 -3
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/RECORD +32 -27
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/WHEEL +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/entry_points.txt +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/licenses/LICENSE +0 -0
- {qbitrr2-5.8.6.dist-info → qbitrr2-5.8.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""qBit Category Manager - Manages torrents in qBit-managed categories with seeding settings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from qBitrr.errors import DelayLoopException
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from qbittorrentapi import TorrentDictionary
|
|
13
|
+
|
|
14
|
+
from qBitrr.main import qBitManager
|
|
15
|
+
|
|
16
|
+
# Sleep timer between processing loops (seconds)
|
|
17
|
+
LOOP_SLEEP_TIMER = 10
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class qBitCategoryManager:
|
|
21
|
+
"""Manages torrents in qBit-managed categories with custom seeding settings."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, instance_name: str, qbit_manager: qBitManager, config: dict):
|
|
24
|
+
"""
|
|
25
|
+
Initialize the qBit category manager.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
instance_name: Name of the qBit instance
|
|
29
|
+
qbit_manager: Reference to qBitManager
|
|
30
|
+
config: Configuration dict containing:
|
|
31
|
+
- managed_categories: List of category names
|
|
32
|
+
- default_seeding: Default seeding settings
|
|
33
|
+
- category_overrides: Per-category seeding overrides
|
|
34
|
+
"""
|
|
35
|
+
self.instance_name = instance_name
|
|
36
|
+
self.qbit_manager = qbit_manager
|
|
37
|
+
self.managed_categories = config.get("managed_categories", [])
|
|
38
|
+
self.default_seeding = config.get("default_seeding", {})
|
|
39
|
+
self.category_overrides = config.get("category_overrides", {})
|
|
40
|
+
self.logger = logging.getLogger(f"qBitrr.qBitCategory.{instance_name}")
|
|
41
|
+
|
|
42
|
+
self.logger.info(
|
|
43
|
+
"Initialized qBit category manager for instance '%s' with %d categories: %s",
|
|
44
|
+
instance_name,
|
|
45
|
+
len(self.managed_categories),
|
|
46
|
+
", ".join(self.managed_categories),
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
def get_client(self):
|
|
50
|
+
"""Get the qBit client for this instance."""
|
|
51
|
+
return self.qbit_manager.get_client(self.instance_name)
|
|
52
|
+
|
|
53
|
+
def get_seeding_config(self, category: str) -> dict:
|
|
54
|
+
"""
|
|
55
|
+
Get seeding configuration for a category.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
category: Category name
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Seeding config dict (per-category override or default)
|
|
62
|
+
"""
|
|
63
|
+
if category in self.category_overrides:
|
|
64
|
+
self.logger.debug("Using category-specific seeding config for '%s'", category)
|
|
65
|
+
return self.category_overrides[category]
|
|
66
|
+
return self.default_seeding
|
|
67
|
+
|
|
68
|
+
def process_torrents(self):
|
|
69
|
+
"""Process all torrents in managed categories."""
|
|
70
|
+
client = self.get_client()
|
|
71
|
+
if not client:
|
|
72
|
+
self.logger.warning(
|
|
73
|
+
"qBit client not available for instance '%s', skipping", self.instance_name
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
for category in self.managed_categories:
|
|
78
|
+
try:
|
|
79
|
+
# Fetch torrents in this category
|
|
80
|
+
torrents = client.torrents_info(category=category)
|
|
81
|
+
self.logger.debug(
|
|
82
|
+
"Processing %d torrents in category '%s'",
|
|
83
|
+
len(torrents),
|
|
84
|
+
category,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
for torrent in torrents:
|
|
88
|
+
self._process_single_torrent(torrent, category)
|
|
89
|
+
|
|
90
|
+
except Exception as e:
|
|
91
|
+
self.logger.error(
|
|
92
|
+
"Error processing category '%s': %s",
|
|
93
|
+
category,
|
|
94
|
+
e,
|
|
95
|
+
exc_info=True,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
def _process_single_torrent(self, torrent: TorrentDictionary, category: str):
|
|
99
|
+
"""
|
|
100
|
+
Process a single torrent - apply seeding settings and check removal.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
torrent: qBittorrent torrent object
|
|
104
|
+
category: Category name
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
config = self.get_seeding_config(category)
|
|
108
|
+
|
|
109
|
+
# Apply seeding limits
|
|
110
|
+
self._apply_seeding_limits(torrent, config)
|
|
111
|
+
|
|
112
|
+
# Check if torrent should be removed
|
|
113
|
+
if self._should_remove_torrent(torrent, config):
|
|
114
|
+
self._remove_torrent(torrent, category)
|
|
115
|
+
|
|
116
|
+
except Exception as e:
|
|
117
|
+
self.logger.error(
|
|
118
|
+
"Error processing torrent '%s' in category '%s': %s",
|
|
119
|
+
torrent.name,
|
|
120
|
+
category,
|
|
121
|
+
e,
|
|
122
|
+
exc_info=True,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
def _apply_seeding_limits(self, torrent: TorrentDictionary, config: dict):
|
|
126
|
+
"""
|
|
127
|
+
Apply seeding limits to a torrent.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
torrent: qBittorrent torrent object
|
|
131
|
+
config: Seeding configuration dict
|
|
132
|
+
"""
|
|
133
|
+
ratio_limit = config.get("MaxUploadRatio", -1)
|
|
134
|
+
time_limit = config.get("MaxSeedingTime", -1)
|
|
135
|
+
|
|
136
|
+
# Prepare share limits
|
|
137
|
+
share_limits = {}
|
|
138
|
+
if ratio_limit > 0:
|
|
139
|
+
share_limits["ratio_limit"] = ratio_limit
|
|
140
|
+
if time_limit > 0:
|
|
141
|
+
share_limits["seeding_time_limit"] = time_limit
|
|
142
|
+
|
|
143
|
+
# Apply share limits if any
|
|
144
|
+
if share_limits:
|
|
145
|
+
try:
|
|
146
|
+
torrent.set_share_limits(**share_limits)
|
|
147
|
+
self.logger.debug(
|
|
148
|
+
"Applied share limits to '%s': %s",
|
|
149
|
+
torrent.name,
|
|
150
|
+
share_limits,
|
|
151
|
+
)
|
|
152
|
+
except Exception as e:
|
|
153
|
+
self.logger.error(
|
|
154
|
+
"Failed to set share limits for '%s': %s",
|
|
155
|
+
torrent.name,
|
|
156
|
+
e,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
# Apply download rate limit
|
|
160
|
+
dl_limit = config.get("DownloadRateLimitPerTorrent", -1)
|
|
161
|
+
if dl_limit >= 0:
|
|
162
|
+
try:
|
|
163
|
+
# qBittorrent expects rate limits in bytes/s
|
|
164
|
+
# Config is in KB/s, so multiply by 1024
|
|
165
|
+
limit_bytes = dl_limit * 1024 if dl_limit > 0 else -1
|
|
166
|
+
torrent.set_download_limit(limit=limit_bytes)
|
|
167
|
+
self.logger.debug(
|
|
168
|
+
"Set download limit for '%s': %d KB/s",
|
|
169
|
+
torrent.name,
|
|
170
|
+
dl_limit,
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
self.logger.error(
|
|
174
|
+
"Failed to set download limit for '%s': %s",
|
|
175
|
+
torrent.name,
|
|
176
|
+
e,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Apply upload rate limit
|
|
180
|
+
ul_limit = config.get("UploadRateLimitPerTorrent", -1)
|
|
181
|
+
if ul_limit >= 0:
|
|
182
|
+
try:
|
|
183
|
+
# qBittorrent expects rate limits in bytes/s
|
|
184
|
+
# Config is in KB/s, so multiply by 1024
|
|
185
|
+
limit_bytes = ul_limit * 1024 if ul_limit > 0 else -1
|
|
186
|
+
torrent.set_upload_limit(limit=limit_bytes)
|
|
187
|
+
self.logger.debug(
|
|
188
|
+
"Set upload limit for '%s': %d KB/s",
|
|
189
|
+
torrent.name,
|
|
190
|
+
ul_limit,
|
|
191
|
+
)
|
|
192
|
+
except Exception as e:
|
|
193
|
+
self.logger.error(
|
|
194
|
+
"Failed to set upload limit for '%s': %s",
|
|
195
|
+
torrent.name,
|
|
196
|
+
e,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
def _should_remove_torrent(self, torrent: TorrentDictionary, config: dict) -> bool:
|
|
200
|
+
"""
|
|
201
|
+
Check if torrent meets removal conditions.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
torrent: qBittorrent torrent object
|
|
205
|
+
config: Seeding configuration dict
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
True if torrent should be removed, False otherwise
|
|
209
|
+
"""
|
|
210
|
+
remove_mode = config.get("RemoveTorrent", -1)
|
|
211
|
+
|
|
212
|
+
if remove_mode == -1:
|
|
213
|
+
return False # Never remove
|
|
214
|
+
|
|
215
|
+
ratio_limit = config.get("MaxUploadRatio", -1)
|
|
216
|
+
time_limit = config.get("MaxSeedingTime", -1)
|
|
217
|
+
|
|
218
|
+
# Check if limits are met
|
|
219
|
+
ratio_met = ratio_limit > 0 and torrent.ratio >= ratio_limit
|
|
220
|
+
time_met = time_limit > 0 and torrent.seeding_time >= time_limit
|
|
221
|
+
|
|
222
|
+
# Determine removal based on mode
|
|
223
|
+
if remove_mode == 1: # Remove on ratio only
|
|
224
|
+
return ratio_met
|
|
225
|
+
elif remove_mode == 2: # Remove on time only
|
|
226
|
+
return time_met
|
|
227
|
+
elif remove_mode == 3: # Remove on OR (either condition)
|
|
228
|
+
return ratio_met or time_met
|
|
229
|
+
elif remove_mode == 4: # Remove on AND (both conditions)
|
|
230
|
+
return ratio_met and time_met
|
|
231
|
+
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
def _remove_torrent(self, torrent: TorrentDictionary, category: str):
|
|
235
|
+
"""
|
|
236
|
+
Remove torrent that met seeding goals.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
torrent: qBittorrent torrent object
|
|
240
|
+
category: Category name
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
self.logger.info(
|
|
244
|
+
"Removing torrent '%s' from category '%s' " "(ratio: %.2f, seeding time: %ds)",
|
|
245
|
+
torrent.name,
|
|
246
|
+
category,
|
|
247
|
+
torrent.ratio,
|
|
248
|
+
torrent.seeding_time,
|
|
249
|
+
)
|
|
250
|
+
# Remove from qBit but keep files
|
|
251
|
+
torrent.delete(delete_files=False)
|
|
252
|
+
except Exception as e:
|
|
253
|
+
self.logger.error(
|
|
254
|
+
"Failed to remove torrent '%s': %s",
|
|
255
|
+
torrent.name,
|
|
256
|
+
e,
|
|
257
|
+
exc_info=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def run_processing_loop(self):
|
|
261
|
+
"""
|
|
262
|
+
Main processing loop for qBit-managed categories.
|
|
263
|
+
|
|
264
|
+
This runs in a separate process and continuously processes torrents.
|
|
265
|
+
"""
|
|
266
|
+
self.logger.info(
|
|
267
|
+
"Starting processing loop for qBit category manager '%s'",
|
|
268
|
+
self.instance_name,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
while not self.qbit_manager.shutdown_event.is_set():
|
|
272
|
+
try:
|
|
273
|
+
self.process_torrents()
|
|
274
|
+
time.sleep(LOOP_SLEEP_TIMER)
|
|
275
|
+
|
|
276
|
+
except DelayLoopException as e:
|
|
277
|
+
# Intentional delay requested
|
|
278
|
+
self.logger.debug("Delaying loop for %d seconds", e.length)
|
|
279
|
+
time.sleep(e.length)
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
self.logger.error(
|
|
283
|
+
"Error in qBit category processing loop: %s",
|
|
284
|
+
e,
|
|
285
|
+
exc_info=True,
|
|
286
|
+
)
|
|
287
|
+
# Sleep before retrying to avoid rapid error loops
|
|
288
|
+
time.sleep(60)
|
|
289
|
+
|
|
290
|
+
self.logger.info(
|
|
291
|
+
"Shutdown event received, stopping processing loop for '%s'",
|
|
292
|
+
self.instance_name,
|
|
293
|
+
)
|