Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions config/config.yml.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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
# <Extension>: # <MANDATORY> This is the extension to match. You can define multiple extensions by splitting with `|` delimiter
# <MANDATORY> Set tag name. Can be a list of tags or a single tag
# tag: <Tag Name>
# 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)
Expand Down
51 changes: 51 additions & 0 deletions docs/Config-Setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | <center>✅</center> |

| Variable | Definition | Default Values | Required |
| :------- | :------------------------------------------------------ | :------------- | :------------------ |
| `tag` | The tag or list of tags to apply when extension matches | None | <center>✅</center> |

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:**

---
Expand Down
40 changes: 40 additions & 0 deletions modules/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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)

Expand Down
31 changes: 31 additions & 0 deletions modules/core/tags.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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
7 changes: 7 additions & 0 deletions web-ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,13 @@ <h3>Configuration Sections</h3>
<span class="validation-indicator"></span>
</a>
</li>
<li class="nav-item" data-section="file_extension">
<a href="#file_extension" class="nav-link">
<span class="material-icons icon">extension</span>
<span class="nav-text">File Extension Tags</span>
<span class="validation-indicator"></span>
</a>
</li>
<li class="nav-item" data-section="nohardlinks">
<a href="#nohardlinks" class="nav-link">
<span class="material-icons icon">link_off</span>
Expand Down
2 changes: 2 additions & 0 deletions web-ui/js/components/config-form.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { directorySchema } from '../config-schemas/directory.js';
import { catSchema } from '../config-schemas/cat.js';
import { catChangeSchema } from '../config-schemas/cat_change.js';
import { trackerSchema } from '../config-schemas/tracker.js';
import { fileExtTagsSchema } from '../config-schemas/file_extension.js';
import { nohardlinksSchema } from '../config-schemas/nohardlinks.js';
import { shareLimitsSchema } from '../config-schemas/share_limits.js';
import { recyclebinSchema } from '../config-schemas/recyclebin.js';
Expand Down Expand Up @@ -54,6 +55,7 @@ class ConfigForm {
cat: catSchema,
cat_change: catChangeSchema,
tracker: trackerSchema,
file_extension: fileExtTagsSchema,
nohardlinks: nohardlinksSchema,
share_limits: shareLimitsSchema,
recyclebin: recyclebinSchema,
Expand Down
44 changes: 44 additions & 0 deletions web-ui/js/config-schemas/file_extension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export const fileExtTagsSchema = {
title: 'File Extension Tags',
description: '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.',
type: 'complex-object',
keyLabel: 'File Extension',
keyDescription: 'File extension to match (without the leading dot, case-insensitive)',
fields: [
{
type: 'documentation',
title: 'File Extension Tags Documentation',
filePath: 'Config-Setup.md',
section: 'file_extension',
defaultExpanded: false
}
],
patternProperties: {
".*": { // Matches any extension
type: 'object',
properties: {
tag: {
label: 'Tag(s)',
description: 'The tag or tags to apply when this file extension is found.',
type: 'array',
items: { type: 'string' }
}
},
required: ['tag'],
additionalProperties: false
}
},
additionalProperties: { // Schema for dynamically added properties (new extension entries)
type: 'object',
properties: {
tag: {
label: 'Tag(s)',
description: 'The tag or tags to apply when this file extension is found.',
type: 'array',
items: { type: 'string' }
}
},
required: ['tag'],
additionalProperties: false
}
};