diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6187fdd5..00672a59 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -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** diff --git a/shelfmark/core/settings_registry.py b/shelfmark/core/settings_registry.py index 2dd23768..fab4ed8b 100644 --- a/shelfmark/core/settings_registry.py +++ b/shelfmark/core/settings_registry.py @@ -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 diff --git a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py index ffaaaf10..e00dfb13 100644 --- a/shelfmark/release_sources/prowlarr/clients/qbittorrent.py +++ b/shelfmark/release_sources/prowlarr/clients/qbittorrent.py @@ -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}") diff --git a/shelfmark/release_sources/prowlarr/settings.py b/shelfmark/release_sources/prowlarr/settings.py index 928e58c3..f7e21de3 100644 --- a/shelfmark/release_sources/prowlarr/settings.py +++ b/shelfmark/release_sources/prowlarr/settings.py @@ -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( diff --git a/src/frontend/src/components/settings/fields/TagListField.tsx b/src/frontend/src/components/settings/fields/TagListField.tsx index ed494474..33547029 100644 --- a/src/frontend/src/components/settings/fields/TagListField.tsx +++ b/src/frontend/src/components/settings/fields/TagListField.tsx @@ -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(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 }: ); }; - diff --git a/src/frontend/src/types/settings.ts b/src/frontend/src/types/settings.ts index efcee3df..e54de206 100644 --- a/src/frontend/src/types/settings.ts +++ b/src/frontend/src/types/settings.ts @@ -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