From 8b7a301721cfa5d5cb00f84317eab565930cce54 Mon Sep 17 00:00:00 2001 From: bobokun Date: Fri, 11 Jul 2025 19:14:17 -0400 Subject: [PATCH 01/10] 4.5.1-develop1 --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index a84947d6..27160e3b 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.0 +4.5.1-develop1 From 8b50b5addff938fc19165b2b8b00c0dbc8d1518d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 11 Jul 2025 19:16:30 -0400 Subject: [PATCH 02/10] [pre-commit.ci] pre-commit autoupdate (#848) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/astral-sh/ruff-pre-commit: v0.11.13 → v0.12.2](https://github.com/astral-sh/ruff-pre-commit/compare/v0.11.13...v0.12.2) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c737d9d6..17bb6c2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,7 +25,7 @@ repos: exclude: ^.github/ - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.11.13 + rev: v0.12.2 hooks: # Run the linter. - id: ruff From b9132955e7573dc645f7a27dbf2ea897655ba899 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Sat, 12 Jul 2025 03:43:13 +0100 Subject: [PATCH 03/10] Add nohardlinks category directory ignore option Adds an option to ignore hardlinks within the same category directory, providing more granular control over hardlink detection. This is ideal for preventing configuring share limits for cross-seeds to be removed if and only if their source files have been removed. The option defaults to True for existing categories and can be configured in the config.yml file. --- config/config.yml.sample | 5 +++ modules/config.py | 7 ++++- modules/core/tag_nohardlinks.py | 6 +++- modules/util.py | 54 +++++++++++++++++++++++---------- 4 files changed, 54 insertions(+), 18 deletions(-) diff --git a/config/config.yml.sample b/config/config.yml.sample index e8c462f2..1dfd6318 100755 --- a/config/config.yml.sample +++ b/config/config.yml.sample @@ -159,6 +159,11 @@ nohardlinks: - BroadcasTheNet # ignore_root_dir var: Will ignore any hardlinks detected in the same root_dir (Default True). ignore_root_dir: true + cross-seed: + # ignore_root_dir var: Will ignore any hardlinks detected in the same root_dir (Default True). + ignore_root_dir: false + # ignore_category_dir var: Will ignore any hardlinks detected in the same category save path (Default True). + ignore_category_dir: true share_limits: # Control how torrent share limits are set depending on the priority of your grouping diff --git a/modules/config.py b/modules/config.py index ed67c20b..c5e0d3fe 100755 --- a/modules/config.py +++ b/modules/config.py @@ -465,7 +465,7 @@ def process_config_nohardlinks(self): self.nohardlinks = {} for cat in self.data["nohardlinks"]: if isinstance(self.data["nohardlinks"], list) and isinstance(cat, str): - self.nohardlinks[cat] = {"exclude_tags": [], "ignore_root_dir": True} + self.nohardlinks[cat] = {"exclude_tags": [], "ignore_root_dir": True, "ignore_category_dir": True} continue if isinstance(cat, dict): cat_str = list(cat.keys())[0] @@ -477,6 +477,7 @@ def process_config_nohardlinks(self): self.nohardlinks[cat_str] = { "exclude_tags": cat[cat_str].get("exclude_tags", []), "ignore_root_dir": cat[cat_str].get("ignore_root_dir", True), + "ignore_category_dir": cat[cat_str].get("ignore_category_dir", True), } if self.nohardlinks[cat_str]["exclude_tags"] is None: self.nohardlinks[cat_str]["exclude_tags"] = [] @@ -484,6 +485,10 @@ def process_config_nohardlinks(self): err = f"Config Error: nohardlinks category {cat_str} attribute ignore_root_dir must be a boolean type" self.notify(err, "Config") raise Failed(err) + if not isinstance(self.nohardlinks[cat_str]["ignore_category_dir"], bool): + err = f"Config Error: nohardlinks category {cat_str} attribute ignore_category_dir must be a boolean type" + self.notify(err, "Config") + raise Failed(err) else: if self.commands["tag_nohardlinks"]: err = "Config Error: nohardlinks must be a list of categories" diff --git a/modules/core/tag_nohardlinks.py b/modules/core/tag_nohardlinks.py index 012da479..a43bcc46 100644 --- a/modules/core/tag_nohardlinks.py +++ b/modules/core/tag_nohardlinks.py @@ -86,13 +86,15 @@ def check_previous_nohardlinks_tagged_torrents(self, has_nohardlinks, torrent, t self.torrents_updated_untagged.append(torrent.name) self.notify_attr_untagged.append(attr) - def _process_torrent_for_nohardlinks(self, torrent, check_hardlinks, ignore_root_dir, exclude_tags, category): + def _process_torrent_for_nohardlinks(self, torrent, check_hardlinks, ignore_root_dir, exclude_tags, category, ignore_category_dir): """Helper method to process a single torrent for nohardlinks tagging.""" tracker = self.qbt.get_tags(self.qbt.get_tracker_urls(torrent.trackers)) has_nohardlinks = check_hardlinks.nohardlink( torrent["content_path"].replace(self.root_dir, self.remote_dir), self.config.notify, ignore_root_dir, + category, + ignore_category_dir ) if any(util.is_tag_in_torrent(tag, torrent.tags) for tag in exclude_tags): # Skip to the next torrent if we find any torrents that are in the exclude tag @@ -125,6 +127,7 @@ def tag_nohardlinks(self): nohardlinks.get(torrent.category, {}).get("ignore_root_dir", True), nohardlinks.get(torrent.category, {}).get("exclude_tags", []), torrent.category, + nohardlinks.get(torrent.category, {}).get("ignore_category_dir", True), ) else: for category in nohardlinks: @@ -145,6 +148,7 @@ def tag_nohardlinks(self): nohardlinks.get(category, {}).get("ignore_root_dir", True), nohardlinks.get(category, {}).get("exclude_tags", []), category, + nohardlinks.get(torrent.category, {}).get("ignore_category_dir", True), ) if self.stats_tagged >= 1: logger.print_line( diff --git a/modules/util.py b/modules/util.py index 1565152b..83c208ed 100755 --- a/modules/util.py +++ b/modules/util.py @@ -68,7 +68,7 @@ def is_tag_in_torrent(check_tag, torrent_tags, exact=True): return tags_to_remove -def format_stats_summary(stats: dict, config) -> list[str]: +def format_stats_summary(stats: dict, config): """ Formats the statistics summary into a human-readable list of strings. @@ -715,17 +715,30 @@ def __init__(self, config): + get_root_files(self.orphaned_dir, "") + get_root_files(self.recycle_dir, "") ) - self.get_inode_count() + self.root_inode_count = self.get_inode_count() + categories = {name: path.replace(self.root_dir, self.remote_dir) for name, path in config.data["cat"].items()} + self.categories_inode_count = self.get_inode_count(categories) - def get_inode_count(self): - self.inode_count = {} + def get_inode_count(self, categories=None): + inode_count = {} for file in self.root_files: # Only check hardlinks for files that are symlinks if os.path.isfile(file) and os.path.islink(file): continue else: + mapped_path = file.replace(self.root_dir, self.remote_dir) + if not categories: + category = "root" + else: + category = None + for name, cat_path in categories.items(): + if mapped_path.startswith(cat_path): + category = name + break + if category is None: + continue # Not in any category, skip try: - inode_no = os.stat(file.replace(self.root_dir, self.remote_dir)).st_ino + inode_no = os.stat(mapped_path).st_ino except PermissionError as perm: logger.warning(f"{perm} : file {file} has permission issues. Skipping...") continue @@ -736,12 +749,15 @@ def get_inode_count(self): logger.stacktrace() logger.error(ex) continue - if inode_no in self.inode_count: - self.inode_count[inode_no] += 1 + if category not in inode_count: + inode_count[category] = {} + if inode_no in inode_count[category]: + inode_count[category][inode_no] += 1 else: - self.inode_count[inode_no] = 1 + inode_count[category][inode_no] = 1 + return inode_count["root"] if not categories else inode_count - def nohardlink(self, file, notify, ignore_root_dir): + def nohardlink(self, file, notify, ignore_root_dir, category, ignore_category_dir): """ Check if there are any hard links Will check if there are any hard links if it passes a file or folder @@ -750,19 +766,23 @@ def nohardlink(self, file, notify, ignore_root_dir): This fixes the bug in #192 """ - def has_hardlinks(self, file, ignore_root_dir): + def has_hardlinks(self, file, ignore_root_dir, category, ignore_category_dir): """ Check if a file has hard links. Args: file (str): The path to the file. ignore_root_dir (bool): Whether to ignore the root directory. + category (str): The category name. + ignore_category_dir (bool): Whether to ignore the torrent category directory. Returns: bool: True if the file has hard links, False otherwise. """ if ignore_root_dir: - return os.stat(file).st_nlink - self.inode_count.get(os.stat(file).st_ino, 1) > 0 + return os.stat(file).st_nlink - self.root_inode_count.get(os.stat(file).st_ino, 1) > 0 + elif ignore_category_dir: + return os.stat(file).st_nlink - self.categories_inode_count.get(category, {}).get(os.stat(file).st_ino, 1) > 0 else: return os.stat(file).st_nlink > 1 @@ -775,10 +795,11 @@ def has_hardlinks(self, file, ignore_root_dir): logger.trace(f"Checking file: {file}") logger.trace(f"Checking file inum: {os.stat(file).st_ino}") logger.trace(f"Checking no of hard links: {os.stat(file).st_nlink}") - logger.trace(f"Checking inode_count dict: {self.inode_count.get(os.stat(file).st_ino)}") + logger.trace(f"Checking root_inode_count dict: {self.root_inode_count.get(os.stat(file).st_ino)}") logger.trace(f"ignore_root_dir: {ignore_root_dir}") + logger.trace(f"ignore_category_dir: {ignore_category_dir} (category: {category})") # https://github.com/StuffAnThings/qbit_manage/issues/291 for more details - if has_hardlinks(self, file, ignore_root_dir): + if has_hardlinks(self, file, ignore_root_dir, category, ignore_category_dir): logger.trace(f"Hardlinks found in {file}.") check_for_hl = False else: @@ -807,9 +828,10 @@ def has_hardlinks(self, file, ignore_root_dir): logger.trace(f"Checking file inum: {os.stat(files).st_ino}") logger.trace(f"Checking file size: {file_size}") logger.trace(f"Checking no of hard links: {file_no_hardlinks}") - logger.trace(f"Checking inode_count dict: {self.inode_count.get(os.stat(files).st_ino)}") + logger.trace(f"Checking root_inode_count dict: {self.root_inode_count.get(os.stat(files).st_ino)}") logger.trace(f"ignore_root_dir: {ignore_root_dir}") - if has_hardlinks(self, files, ignore_root_dir) and file_size >= (largest_file_size * threshold): + logger.trace(f"ignore_category_dir: {ignore_category_dir} (category: {category})") + if has_hardlinks(self, files, ignore_root_dir, category, ignore_category_dir) and file_size >= (largest_file_size * threshold): logger.trace(f"Hardlinks found in {files}.") check_for_hl = False except PermissionError as perm: @@ -997,7 +1019,7 @@ def __repr__(self): return super().__repr__() -def get_matching_config_files(config_pattern: str, default_dir: str) -> list: +def get_matching_config_files(config_pattern: str, default_dir: str): """Get list of config files matching a pattern. Args: From 6698dc00908e5f5321810982c9d9510718b1a348 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sat, 12 Jul 2025 03:13:22 +0000 Subject: [PATCH 04/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- modules/core/tag_nohardlinks.py | 6 ++++-- modules/util.py | 4 +++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/modules/core/tag_nohardlinks.py b/modules/core/tag_nohardlinks.py index a43bcc46..8ab59950 100644 --- a/modules/core/tag_nohardlinks.py +++ b/modules/core/tag_nohardlinks.py @@ -86,7 +86,9 @@ def check_previous_nohardlinks_tagged_torrents(self, has_nohardlinks, torrent, t self.torrents_updated_untagged.append(torrent.name) self.notify_attr_untagged.append(attr) - def _process_torrent_for_nohardlinks(self, torrent, check_hardlinks, ignore_root_dir, exclude_tags, category, ignore_category_dir): + def _process_torrent_for_nohardlinks( + self, torrent, check_hardlinks, ignore_root_dir, exclude_tags, category, ignore_category_dir + ): """Helper method to process a single torrent for nohardlinks tagging.""" tracker = self.qbt.get_tags(self.qbt.get_tracker_urls(torrent.trackers)) has_nohardlinks = check_hardlinks.nohardlink( @@ -94,7 +96,7 @@ def _process_torrent_for_nohardlinks(self, torrent, check_hardlinks, ignore_root self.config.notify, ignore_root_dir, category, - ignore_category_dir + ignore_category_dir, ) if any(util.is_tag_in_torrent(tag, torrent.tags) for tag in exclude_tags): # Skip to the next torrent if we find any torrents that are in the exclude tag diff --git a/modules/util.py b/modules/util.py index 83c208ed..2afff17a 100755 --- a/modules/util.py +++ b/modules/util.py @@ -831,7 +831,9 @@ def has_hardlinks(self, file, ignore_root_dir, category, ignore_category_dir): logger.trace(f"Checking root_inode_count dict: {self.root_inode_count.get(os.stat(files).st_ino)}") logger.trace(f"ignore_root_dir: {ignore_root_dir}") logger.trace(f"ignore_category_dir: {ignore_category_dir} (category: {category})") - if has_hardlinks(self, files, ignore_root_dir, category, ignore_category_dir) and file_size >= (largest_file_size * threshold): + if has_hardlinks(self, files, ignore_root_dir, category, ignore_category_dir) and file_size >= ( + largest_file_size * threshold + ): logger.trace(f"Hardlinks found in {files}.") check_for_hl = False except PermissionError as perm: From ab8d4f2e676cb57136c26a7c3bfccd80f8f00f7d Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Sun, 3 Aug 2025 02:36:34 +0100 Subject: [PATCH 05/10] Bump version --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 446c4bb9..72185f6f 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.1-dev +4.5.2-develop From 8632f294b6424a342e6525177647d74006599769 Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Sun, 3 Aug 2025 05:06:30 +0100 Subject: [PATCH 06/10] Updated docs and WebUI schema --- docs/Config-Setup.md | 1 + web-ui/js/config-schemas/nohardlinks.js | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/docs/Config-Setup.md b/docs/Config-Setup.md index 9351a89b..bcbc831b 100644 --- a/docs/Config-Setup.md +++ b/docs/Config-Setup.md @@ -152,6 +152,7 @@ Beyond this you'll need to use one of the [categories](#cat) above as the key. | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------- | :----------------- | | `exclude_tags` | List of tags to exclude from the check. Torrents with any of these tags will not be processed. This is useful to exclude certain trackers from being scanned for hardlinking purposes | None |
| | `ignore_root_dir` | Ignore any hardlinks detected in the same [root_dir](#directory) | True |
| +| `ignore_category_dir` | Ignore any hardlinks detected in the same [category_dir](#cat) | True |
| ## **share_limits:** diff --git a/web-ui/js/config-schemas/nohardlinks.js b/web-ui/js/config-schemas/nohardlinks.js index be3d3f52..19fb1202 100644 --- a/web-ui/js/config-schemas/nohardlinks.js +++ b/web-ui/js/config-schemas/nohardlinks.js @@ -20,6 +20,12 @@ export const nohardlinksSchema = { label: 'Ignore Root Directory', description: 'If true, ignore hardlinks found within the same root directory.', default: true + }, + ignore_category_dir: { + type: 'boolean', + label: 'Ignore Category Directory', + description: 'If true, ignore hardlinks found within the same category directory.', + default: true } }, additionalProperties: false @@ -39,6 +45,12 @@ export const nohardlinksSchema = { label: 'Ignore Root Directory', description: 'If true, ignore hardlinks found within the same root directory.', default: true + }, + ignore_category_dir: { + type: 'boolean', + label: 'Ignore Category Directory', + description: 'If true, ignore hardlinks found within the same category directory.', + default: true } }, additionalProperties: false From a4b3d9f27819b7ef16c2149abc5385f5350645d9 Mon Sep 17 00:00:00 2001 From: bobokun Date: Sun, 3 Aug 2025 15:21:09 -0400 Subject: [PATCH 07/10] Update VERSION to 4.5.3-develop1 [skip ci] --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 6cedcff6..ea4b9cdf 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.2 +4.5.3-develop1 From e3fb91fbfc7ceac8351fda2a9a19aecf4f3081a5 Mon Sep 17 00:00:00 2001 From: bobokun Date: Sun, 3 Aug 2025 15:24:03 -0400 Subject: [PATCH 08/10] ci(workflows): improve develop branch update workflow with better error handling - Use GitHub Actions bot credentials for git operations - Add proper branch existence checks and creation logic - Implement comprehensive error handling for push operations - Add detailed logging for debugging workflow issues - Use PAT token fallback for branch protection bypass - Include [skip ci] flag to prevent recursive workflow triggers --- .github/workflows/latest.yml | 51 ++++++++++++++++++++++++++++-------- VERSION | 2 +- 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/.github/workflows/latest.yml b/.github/workflows/latest.yml index 48be6f87..62595d3c 100644 --- a/.github/workflows/latest.yml +++ b/.github/workflows/latest.yml @@ -66,32 +66,61 @@ jobs: - name: Check Out Repo uses: actions/checkout@v4 with: - token: ${{ secrets.GITHUB_TOKEN }} + token: ${{ secrets.PAT || secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Update develop branch run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - - # Checkout develop branch - git checkout develop + # Configure git with GitHub Actions bot credentials + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" + + # Ensure we have the latest remote refs + git fetch origin + + # Check if develop branch exists locally, if not create it + if git show-ref --verify --quiet refs/heads/develop; then + echo "Local develop branch exists, checking it out" + git checkout develop + else + echo "Local develop branch doesn't exist, creating from remote" + git checkout -b develop origin/develop + fi # Reset develop to master + echo "Resetting develop branch to match master..." git reset --hard origin/master - # Read current version and bump minor patch + # Read current version and bump patch CURRENT_VERSION=$(cat VERSION) + echo "Current version: $CURRENT_VERSION" + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" NEW_PATCH=$((PATCH + 1)) NEW_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}-develop1" + echo "New version: $NEW_VERSION" + # Update VERSION file echo "$NEW_VERSION" > VERSION + # Check if there are changes to commit + if git diff --quiet; then + echo "No changes to commit" + exit 0 + fi + # Commit the change git add VERSION - git commit -m "Update VERSION to $NEW_VERSION" - - # Force push develop branch - git push --force origin develop + git commit -m "Update VERSION to $NEW_VERSION [skip ci]" + + # Push develop branch (force push since we reset to master) + # Note: This requires PAT_TOKEN secret with admin privileges to bypass branch protection + echo "Pushing changes to develop branch..." + if ! git push --force origin develop; then + echo "Failed to push to develop branch. This may be due to branch protection rules." + echo "Ensure PAT_TOKEN secret is set with admin privileges or disable branch protection for this workflow." + exit 1 + fi + + echo "Successfully updated develop branch to $NEW_VERSION" diff --git a/VERSION b/VERSION index ea4b9cdf..71dd2442 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.3-develop1 +4.5.3-develop2 From 3f691cb2ec743a32de8dd4b49528c275b518e500 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 3 Aug 2025 19:24:34 +0000 Subject: [PATCH 09/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VERSION b/VERSION index 45bfd794..71dd2442 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -4.5.3-develop2 \ No newline at end of file +4.5.3-develop2 From 388e96cf982bb6aab2e4175786e715a00a2d17ed Mon Sep 17 00:00:00 2001 From: Jacob Clayden Date: Mon, 4 Aug 2025 03:44:55 +0100 Subject: [PATCH 10/10] Apply suggestions from code review Co-authored-by: bobokun <12660469+bobokun@users.noreply.github.com> --- modules/core/tag_nohardlinks.py | 2 +- modules/util.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/modules/core/tag_nohardlinks.py b/modules/core/tag_nohardlinks.py index 3e8a3609..ca5a3933 100644 --- a/modules/core/tag_nohardlinks.py +++ b/modules/core/tag_nohardlinks.py @@ -153,7 +153,7 @@ def tag_nohardlinks(self): nohardlinks.get(category, {}).get("ignore_root_dir", True), nohardlinks.get(category, {}).get("exclude_tags", []), category, - nohardlinks.get(torrent.category, {}).get("ignore_category_dir", True), + nohardlinks.get(category, {}).get("ignore_category_dir", True), ) if self.stats_tagged >= 1: logger.print_line( diff --git a/modules/util.py b/modules/util.py index d9b9cb5d..cabfc4b4 100755 --- a/modules/util.py +++ b/modules/util.py @@ -68,7 +68,7 @@ def is_tag_in_torrent(check_tag, torrent_tags, exact=True): return tags_to_remove -def format_stats_summary(stats: dict, config): +def format_stats_summary(stats: dict, config) -> list[str]: """ Formats the statistics summary into a human-readable list of strings. @@ -1070,7 +1070,7 @@ def __repr__(self): return super().__repr__() -def get_matching_config_files(config_pattern: str, default_dir: str): +def get_matching_config_files(config_pattern: str, default_dir: str) -> list: """Get list of config files matching a pattern. Args: