diff --git a/config/config.yml.sample b/config/config.yml.sample index 2ff974c2..124faf54 100755 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -140,6 +140,37 @@ tracker: other: tag: other +file_extension: + # Tag torrents based on file extensions found in the torrent + # This will check all files in the torrent and apply tags if any file matches the specified extension + # Supports compound extensions like "tar.gz" - the extension is matched at the end of the filename + # : # This is the extension to match. You can define multiple extensions by splitting with `|` delimiter + # Set tag name. Can be a list of tags or a single tag + # tag: + # Extensions are case-insensitive and should NOT include the leading dot + + # Single extension with single tag + iso: + tag: ISO + + # Single extension with multiple tags + rar: + tag: + - RAR + - Archived + + # Multiple extensions sharing the same tag(s) using | delimiter + tar.gz|tar.bz2|tar.xz: + tag: TarArchive + + # Another example with multiple extensions + zip|7z: + tag: Archived + + # Video formats + # mkv|mp4|avi: + # tag: Video + nohardlinks: # Tag Movies/Series that are not hard linked outside the root directory # Mandatory to fill out directory parameter above to use this function (root_dir/remote_dir) diff --git a/docs/Config-Setup.md b/docs/Config-Setup.md index 5e600189..c6e98c3b 100644 --- a/docs/Config-Setup.md +++ b/docs/Config-Setup.md @@ -161,6 +161,57 @@ If you are unsure what key word to use. Simply select a torrent within qB and do > [!NOTE] > If `other` is not used then trackers will be auto added. +## **file_extension:** + +--- + +This section defines tags to apply based on file extensions found in torrents. + +| Configuration | Definition | Required | +| :------------ | :----------------------------------------------------------------------------------------------- | :------------------ | +| `key` | File extension to match. You can define multiple extensions by splitting with `|` delimiter |
| + +| Variable | Definition | Default Values | Required | +| :------- | :------------------------------------------------------ | :------------- | :------------------ | +| `tag` | The tag or list of tags to apply when extension matches | None |
| + +This feature checks all files within a torrent and applies tags if any file matches the specified extension(s). + +**Key Features:** +- **Case-insensitive matching**: Extensions like `ISO`, `iso`, and `.iso` are all treated the same +- **Compound extension support**: Can match multi-part extensions like `tar.gz`, `tar.bz2`, etc. +- **Multiple extensions per tag**: Use `|` delimiter to group extensions (e.g., `tar.gz|tar.bz2|tar.xz`) +- **Multiple tags per extension**: Can apply multiple tags to a single extension or group + +**Examples:** + +```yaml +file_extension: + # Single extension with single tag + iso: + tag: ISO + + # Single extension with multiple tags + rar: + tag: + - RAR + - Archived + + # Multiple extensions sharing the same tag(s) + tar.gz|tar.bz2|tar.xz: + tag: TarArchive + + # Group common archive formats + zip|7z: + tag: Archived +``` + +> [!TIP] +> Extensions should NOT include the leading dot. Use `iso` not `.iso`. + +> [!NOTE] +> If a file matches multiple configured extensions, all corresponding tags will be applied. For example, a file named `backup.tar.gz` will match both `tar.gz` and `gz` if both are configured. + ## **nohardlinks:** --- diff --git a/modules/config.py b/modules/config.py index 9d17c8f9..54cba9a1 100755 --- a/modules/config.py +++ b/modules/config.py @@ -80,6 +80,7 @@ def load_config(self): self.processs_config_recyclebin() self.process_config_directories() self.process_config_orphaned() + self.process_config_file_extension() def configure_qbt(self): """ @@ -998,6 +999,45 @@ def process_config_orphaned(self): else self.orphaned["exclude_patterns"] ) + def process_config_file_extension(self): + """ + Process the file extension tags configuration data. + This method processes the file_extension section which maps file extensions to tags. + Multiple extensions can be grouped using | delimiter: ext1|ext2|ext3: {tag: tag_name} + """ + self.file_extension = {} + if "file_extension" in self.data and self.data["file_extension"] is not None: + for ext_key, tag_data in self.data["file_extension"].items(): + if not isinstance(tag_data, dict): + logger.warning( + f"Invalid file_extension configuration for extension '{ext_key}'. " + f"Must be a dict with 'tag' key. Skipping." + ) + continue + + if "tag" not in tag_data: + logger.warning( + f"Invalid file_extension configuration for extension '{ext_key}'. Missing 'tag' key. Skipping." + ) + continue + + tags_list = [] + if isinstance(tag_data["tag"], str): + tags_list = [tag_data["tag"]] + elif isinstance(tag_data["tag"], list): + tags_list = tag_data["tag"] + else: + logger.warning( + f"Invalid file_extension configuration for extension '{ext_key}'. " + f"Tag value must be a string or list. Skipping." + ) + continue + + extensions = [e.strip().lower().lstrip(".") for e in ext_key.split("|") if e] + for ext in extensions: + self.file_extension[ext] = tags_list + logger.trace(f"File extension tag mapping: .{ext} -> {tags_list}") + def __retry_on_connect(exception): return isinstance(exception.__cause__, ConnectionError) diff --git a/modules/core/tags.py b/modules/core/tags.py index 8cc20423..427dae71 100644 --- a/modules/core/tags.py +++ b/modules/core/tags.py @@ -19,6 +19,7 @@ def __init__(self, qbit_manager, hashes: list[str] = None): self.stalled_tag = qbit_manager.config.stalled_tag self.private_tag = qbit_manager.config.private_tag self.tag_stalled_torrents = self.config.settings["tag_stalled_torrents"] + self.file_extension = qbit_manager.config.file_extension self.tags() self.config.webhooks_factory.notify(self.torrents_updated, self.notify_attr, group_by="tag") @@ -46,6 +47,10 @@ def tags(self): body += logger.print_line(logger.insert_space(f"Tracker: {tracker['url']}", 8), self.config.loglevel) if not self.config.dry_run: torrent.remove_tags(self.stalled_tag) + + # Get file extension tags for this torrent + file_extension = self.get_file_extension(torrent) + if ( torrent.tags == "" or not util.is_tag_in_torrent(tracker["tag"], torrent.tags) @@ -59,12 +64,16 @@ def tags(self): and not util.is_tag_in_torrent(self.private_tag, torrent.tags) and self.qbt.is_torrent_private(torrent) ) + or (file_extension and not all(util.is_tag_in_torrent(tag, torrent.tags) for tag in file_extension)) ): tags_to_add = tracker["tag"].copy() if self.tag_stalled_torrents and torrent.state == "stalledDL": tags_to_add.append(self.stalled_tag) if self.private_tag and self.qbt.is_torrent_private(torrent): tags_to_add.append(self.private_tag) + for tag in file_extension: + if tag not in tags_to_add: + tags_to_add.append(tag) if tags_to_add: t_name = torrent.name self.stats += len(tags_to_add) @@ -100,3 +109,25 @@ def tags(self): end_time = time.time() duration = end_time - start_time logger.debug(f"Tags command completed in {duration:.2f} seconds") + + def get_file_extension(self, torrent): + """Check torrent files for configured file extensions and return matching tags.""" + if not self.file_extension: + return [] + + tags_to_add = [] + extensions_found = set() + + for file in torrent.files: + for ext, ext_tags in self.file_extension.items(): + if file.name.lower().endswith(f".{ext}") and ext not in extensions_found: + extensions_found.add(ext) + # Add the configured tags for this extension + for tag in ext_tags: + if tag not in tags_to_add: + tags_to_add.append(tag) + logger.trace( + f"Found extension '.{ext}' in file '{file.name}' from torrent '{torrent.name}', adding tags: {ext_tags}" + ) + + return tags_to_add diff --git a/web-ui/index.html b/web-ui/index.html index 520001cf..4db3c51c 100755 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -159,6 +159,13 @@

Configuration Sections

+