mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 09:31:11 +01:00
feat: Add tag support to qBittorrent (#610)
Added support for adding tag(s) to torrents sent to qBittorrent via shelfmark.  
This commit is contained in:
@@ -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**
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user