feat: Add tag support to qBittorrent (#610)

Added support for adding tag(s) to torrents sent to qBittorrent via
shelfmark.
![Screenshot 2026-02-11 at 3 58
20 AM](https://github.com/user-attachments/assets/aa9b440a-27fd-4166-953b-31f5179688a3)
![Screenshot 2026-02-11 at 3 53
14 AM](https://github.com/user-attachments/assets/15084b44-9a68-493c-85e8-328c92206c85)
This commit is contained in:
arjunsrinivasan1997
2026-02-12 14:52:34 +00:00
committed by GitHub
parent 5bed0b20f4
commit a7064939ce
6 changed files with 76 additions and 15 deletions
+10
View File
@@ -1315,6 +1315,7 @@ Automatically retry search without category filtering if no results are found
| `QBITTORRENT_PASSWORD` | qBittorrent Web UI password | string (secret) | _none_ |
| `QBITTORRENT_CATEGORY` | Category to assign to book downloads in qBittorrent | string | `books` |
| `QBITTORRENT_CATEGORY_AUDIOBOOK` | Category for audiobook downloads. Leave empty to use the book category. | string | _empty string_ |
| `QBITTORRENT_TAG` | Comma-separated list of tags to assign to qBittorrent downloads. Leave empty for no tags. | string | _empty string_ |
| `TRANSMISSION_URL` | URL of your Transmission instance | string | _none_ |
| `TRANSMISSION_USERNAME` | Transmission RPC username (if authentication enabled) | string | _none_ |
| `TRANSMISSION_PASSWORD` | Transmission RPC password | string (secret) | _none_ |
@@ -1400,6 +1401,15 @@ Category for audiobook downloads. Leave empty to use the book category.
- **Type:** string
- **Default:** _empty string_
#### `QBITTORRENT_TAG`
**Tags**
Comma-separated list of tags to assign to qBittorrent downloads. Leave empty for no tags.
- **Type:** string
- **Default:** _empty string_
#### `TRANSMISSION_URL`
**Transmission URL**
+2
View File
@@ -89,6 +89,7 @@ class TagListField(FieldBase):
"""Editable list of free-form string values (tag/chip input)."""
placeholder: str = ""
default: List[str] = field(default_factory=list)
normalize_urls: bool = True
@dataclass
@@ -759,6 +760,7 @@ def serialize_field(field: SettingsField, tab_name: str, include_value: bool = T
result["variant"] = field.variant
elif isinstance(field, TagListField):
result["placeholder"] = field.placeholder
result["normalizeUrls"] = field.normalize_urls
elif isinstance(field, OrderableListField):
# Support callable options for lazy evaluation (avoids circular imports)
options = field.options() if callable(field.options) else field.options
@@ -32,6 +32,35 @@ def _hashes_match(hash1: str, hash2: str) -> bool:
return False
def _normalize_tags(raw_tags: object) -> list[str]:
"""Normalize tag input to a clean, de-duplicated list of strings."""
if raw_tags is None:
return []
if isinstance(raw_tags, str):
parts = [part.strip() for part in raw_tags.split(",")]
elif isinstance(raw_tags, (list, tuple, set)):
parts = []
for item in raw_tags:
if item is None:
continue
parts.append(str(item).strip())
else:
parts = [str(raw_tags).strip()] if raw_tags else []
tags: list[str] = []
seen = set()
for part in parts:
if not part:
continue
if part in seen:
continue
seen.add(part)
tags.append(part)
return tags
@register_client("torrent")
class QBittorrentClient(DownloadClient):
"""qBittorrent download client."""
@@ -110,6 +139,7 @@ class QBittorrentClient(DownloadClient):
password=config.get("QBITTORRENT_PASSWORD", ""),
)
self._category = config.get("QBITTORRENT_CATEGORY", "books")
self._tags = _normalize_tags(config.get("QBITTORRENT_TAG", []))
def _get_torrents_info(
@@ -256,6 +286,7 @@ class QBittorrentClient(DownloadClient):
try:
# Use configured category if not explicitly provided
category = category or self._category
tags = self._tags
# Ensure category exists (may already exist, which is fine)
try:
@@ -271,19 +302,24 @@ class QBittorrentClient(DownloadClient):
torrent_data = torrent_info.torrent_data
# Add the torrent - use file content if we have it, otherwise URL
add_kwargs = {
"category": category,
"rename": name,
}
if tags:
add_kwargs["tags"] = ",".join(tags)
if torrent_data:
result = self._client.torrents_add(
torrent_files=torrent_data,
category=category,
rename=name,
**add_kwargs,
)
else:
# Use magnet URL if available, otherwise original URL
add_url = torrent_info.magnet_url or url
result = self._client.torrents_add(
urls=add_url,
category=category,
rename=name,
**add_kwargs,
)
logger.debug(f"qBittorrent add result: {result}")
@@ -18,6 +18,7 @@ from shelfmark.core.settings_registry import (
ActionButton,
SelectField,
MultiSelectField,
TagListField,
)
from shelfmark.core.utils import normalize_http_url
@@ -555,6 +556,15 @@ def prowlarr_clients_settings():
default="",
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
TagListField(
key="QBITTORRENT_TAG",
label="Tags",
description="Tag(s) to assign to qBittorrent downloads. Leave empty for no tags.",
placeholder="",
default=[],
normalize_urls=False,
show_when={"field": "PROWLARR_TORRENT_CLIENT", "value": "qbittorrent"},
),
# --- Transmission Settings ---
TextField(
@@ -9,7 +9,7 @@ interface TagListFieldProps {
requiredTags?: string[]; // Tags that cannot be removed
}
function normalizeTag(raw: string): string {
function normalizeTag(raw: string, normalizeUrls: boolean): string {
let s = raw.trim();
if (!s) return '';
@@ -21,16 +21,18 @@ function normalizeTag(raw: string): string {
s = s.slice(1, -1).trim();
}
// Avoid special sentinel values.
if (s.toLowerCase() === 'auto') return '';
if (normalizeUrls) {
// Avoid special sentinel values.
if (s.toLowerCase() === 'auto') return '';
// Basic URL normalization to keep UX friendly; backend also normalizes on save.
// Only add https:// if it looks like a domain (contains a dot) and has no protocol.
// This avoids adding prefixes to non-URL values like OIDC scopes (openid, email, etc.)
if (!s.includes('://') && !s.startsWith('/') && s.includes('.')) {
s = `https://${s}`;
// Basic URL normalization to keep UX friendly; backend also normalizes on save.
// Only add https:// if it looks like a domain (contains a dot) and has no protocol.
// This avoids adding prefixes to non-URL values like OIDC scopes (openid, email, etc.)
if (!s.includes('://') && !s.startsWith('/') && s.includes('.')) {
s = `https://${s}`;
}
s = s.replace(/\/+$/, '');
}
s = s.replace(/\/+$/, '');
return s.trim();
}
@@ -39,6 +41,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
const required = requiredTags ?? [];
const inputRef = useRef<HTMLInputElement>(null);
const [draft, setDraft] = useState('');
const normalizeUrls = field.normalizeUrls ?? true;
const tags = useMemo(() => (value ?? []).map(String).filter((t) => t.trim() !== ''), [value]);
@@ -52,7 +55,7 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
const next = [...tags];
for (const part of parts) {
const normalized = normalizeTag(part);
const normalized = normalizeTag(part, normalizeUrls);
if (!normalized) continue;
if (next.includes(normalized)) continue;
next.push(normalized);
@@ -152,4 +155,3 @@ export const TagListField = ({ field, value, onChange, disabled, requiredTags }:
</div>
);
};
+1
View File
@@ -99,6 +99,7 @@ export interface TagListFieldConfig extends BaseField {
type: 'TagListField';
value: string[];
placeholder?: string;
normalizeUrls?: boolean;
}
// OrderableListField types - generic drag-and-drop reorderable list