diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index 6f6c8a0e..bed5f73b 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -55,16 +55,16 @@ Workflows that run on **PR to main** use the environment **`approval-required`**
- Run manually when needed from **Actions → Compatibility → Run workflow**
### Benchmark Workflow (benchmark.yml)
-- **Triggers**: `workflow_dispatch` only (manual)
-- **Purpose**: Performance benchmarking and trend tracking
+- **Triggers**: `workflow_dispatch` and pull_request/PR paths updates
+- **Purpose**: Performance benchmarking, baseline comparison, and trend tracking
- **Runs**:
- - Hash verification benchmark
- - Disk I/O benchmark
- - Piece assembly benchmark
- - Loopback throughput benchmark
- - Encryption benchmark
+ - Run benchmark suite for `head` changeset and compare against `base`
+ - Evaluate deltas against `dev/benchmark_thresholds.toml`
+ - Render committed docs from the comparison output and trend history
- **Rationale**:
- - Run manually when needed; can commit results to the repo when run from `main`
+ - Benchmarks stay out of pre-commit for faster local commits
+ - PRs to `main` can validate regressions before merge
+ - Commits only generated reports under `docs/en/reports/benchmarks/generated/`
### Security Workflow (security.yml)
- **Triggers**: PR to `main` (runs after approval), weekly schedule, `workflow_dispatch`
@@ -233,7 +233,8 @@ Workflows that run on **PR to main** use the environment **`approval-required`**
- **CI Pipeline** (`ci.yml`) and **Test** (`test.yml`): PR to `main` (with approval) or manual run
- **Version Check** (`version-check.yml`): PR to `main` (with approval) or manual run
- **Security** (`security.yml`): PR to `main` (with approval), weekly schedule, or manual run
-- **Compatibility** (`compatibility.yml`) and **Benchmarks** (`benchmark.yml`): manual run only
+- **Compatibility** (`compatibility.yml`): manual run only
+- **Benchmark** (`benchmark.yml`): manual run and pull_request flow as defined in the workflow
---
diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml
index d9af2361..f6cf6760 100644
--- a/.github/workflows/benchmark.yml
+++ b/.github/workflows/benchmark.yml
@@ -1,17 +1,32 @@
name: Benchmark
on:
- workflow_dispatch: # Manual only, never automatic
+ workflow_dispatch:
+ inputs:
+ base_ref:
+ description: Base branch/commit to compare against
+ required: false
+ default: main
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+env:
+ BENCH_BASE_DIR: ${{ github.workspace }}/.ci/benchmark_base
+ BENCH_HEAD_DIR: ${{ github.workspace }}/.ci/benchmark_head
+ BENCH_COMPARE_DIR: ${{ github.workspace }}/docs/en/reports/benchmarks/generated
+ BENCH_CONFIG_FILE: docs/examples/example-config-performance.toml
+ BENCH_QUICK: "1"
+ BENCH_KEEP_HISTORY: "20"
concurrency:
group: benchmark-write-${{ github.ref }}
- cancel-in-progress: false
+ cancel-in-progress: true
jobs:
benchmark:
name: benchmark
runs-on: ubuntu-latest
- if: github.event_name == 'push' || github.event_name == 'workflow_dispatch'
+ if: github.event_name == 'pull_request' || github.event_name == 'workflow_dispatch'
permissions:
contents: write # Required to commit benchmark results
actions: read
@@ -35,42 +50,131 @@ jobs:
- name: Install dependencies
run: |
uv sync --dev
-
- - name: Run hash verification benchmark
- run: |
- uv run python tests/performance/bench_hash_verify.py --quick --record-mode=commit --config-file docs/examples/example-config-performance.toml
-
- - name: Run disk I/O benchmark
+
+ - name: Run head benchmark suite
run: |
- uv run python tests/performance/bench_disk_io.py --quick --sizes 256KiB 1MiB --record-mode=commit --config-file docs/examples/example-config-performance.toml
-
- - name: Run piece assembly benchmark
+ mkdir -p "${BENCH_HEAD_DIR}"
+ if [ "${BENCH_QUICK}" = "1" ]; then
+ QUICK_ARG="--quick"
+ else
+ QUICK_ARG=""
+ fi
+
+ uv run python tests/performance/bench_hash_verify.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_HEAD_DIR}/bench_hash_verify.json"
+
+ uv run python tests/performance/bench_disk_io.py ${QUICK_ARG} --sizes 256KiB 1MiB \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_HEAD_DIR}/bench_disk_io.json"
+
+ uv run python tests/performance/bench_piece_assembly.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_HEAD_DIR}/bench_piece_assembly.json"
+
+ uv run python tests/performance/bench_loopback_throughput.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_HEAD_DIR}/bench_loopback_throughput.json"
+
+ uv run python tests/performance/bench_encryption.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_HEAD_DIR}/bench_encryption.json"
+
+ - name: Prepare base checkout
run: |
- uv run python tests/performance/bench_piece_assembly.py --quick --record-mode=commit --config-file docs/examples/example-config-performance.toml
-
- - name: Run loopback throughput benchmark
+ if [ "${{ github.event_name }}" = "pull_request" ]; then
+ BASE_REF="${{ github.event.pull_request.base.sha }}"
+ else
+ BASE_REF="${{ github.event.inputs.base_ref }}"
+ fi
+ rm -rf "${BENCH_BASE_DIR}"
+ mkdir -p "${BENCH_BASE_DIR}"
+ git worktree add --detach "${BENCH_BASE_DIR}" "${BASE_REF}"
+
+ - name: Run base benchmark suite
run: |
- uv run python tests/performance/bench_loopback_throughput.py --quick --record-mode=commit --config-file docs/examples/example-config-performance.toml
-
- - name: Run encryption benchmark
+ mkdir -p "${BENCH_BASE_DIR}"
+ if [ "${BENCH_QUICK}" = "1" ]; then
+ QUICK_ARG="--quick"
+ else
+ QUICK_ARG=""
+ fi
+
+ (cd "${BENCH_BASE_DIR}" && \
+ uv run python tests/performance/bench_hash_verify.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_BASE_DIR}/bench_hash_verify.json")
+
+ (cd "${BENCH_BASE_DIR}" && \
+ uv run python tests/performance/bench_disk_io.py ${QUICK_ARG} --sizes 256KiB 1MiB \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_BASE_DIR}/bench_disk_io.json")
+
+ (cd "${BENCH_BASE_DIR}" && \
+ uv run python tests/performance/bench_piece_assembly.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_BASE_DIR}/bench_piece_assembly.json")
+
+ (cd "${BENCH_BASE_DIR}" && \
+ uv run python tests/performance/bench_loopback_throughput.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_BASE_DIR}/bench_loopback_throughput.json")
+
+ (cd "${BENCH_BASE_DIR}" && \
+ uv run python tests/performance/bench_encryption.py ${QUICK_ARG} \
+ --record-mode=none \
+ --config-file "${BENCH_CONFIG_FILE}" \
+ --json-out "${BENCH_BASE_DIR}/bench_encryption.json")
+
+ - name: Compare and render benchmark reports
run: |
- uv run python tests/performance/bench_encryption.py --quick --record-mode=commit --config-file docs/examples/example-config-performance.toml
-
+ mkdir -p "${BENCH_COMPARE_DIR}"
+ uv run python dev/scripts/compare_benchmark_json.py \
+ --base "${BENCH_BASE_DIR}" \
+ --head "${BENCH_HEAD_DIR}" \
+ --thresholds dev/benchmark_thresholds.toml \
+ --output "${BENCH_COMPARE_DIR}/comparison_latest.json"
+
+ uv run python dev/scripts/render_benchmark_docs.py \
+ --comparison "${BENCH_COMPARE_DIR}/comparison_latest.json" \
+ --history "${BENCH_COMPARE_DIR}/benchmark_history.json" \
+ --out-dir "${BENCH_COMPARE_DIR}" \
+ --keep "${BENCH_KEEP_HISTORY}"
+
- name: Upload benchmark artifacts
if: always()
uses: actions/upload-artifact@v4
with:
- name: benchmark-results
+ name: benchmark-ci-artifacts
path: |
- docs/reports/benchmarks/runs/*.json
- docs/reports/benchmarks/timeseries/*.json
+ ${{ env.BENCH_BASE_DIR }}/*.json
+ ${{ env.BENCH_HEAD_DIR }}/*.json
+ docs/en/reports/benchmarks/generated/comparison_latest.json
+ docs/en/reports/benchmarks/generated/comparison_latest.md
+ docs/en/reports/benchmarks/generated/trend_charts.md
+ docs/en/reports/benchmarks/generated/benchmark_history.json
retention-days: 90
- name: Commit benchmark results
- if: github.ref == 'refs/heads/main' && github.event_name == 'push'
+ if: github.event_name == 'workflow_dispatch'
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
- git add -f docs/reports/benchmarks/
- git diff --staged --quiet || git commit -m "ci: record benchmark results [skip ci]"
- git push
+ git add docs/en/reports/benchmarks/generated/comparison_latest.md \
+ docs/en/reports/benchmarks/generated/comparison_latest.json \
+ docs/en/reports/benchmarks/generated/trend_charts.md \
+ docs/en/reports/benchmarks/generated/benchmark_history.json \
+ docs/en/reports/benchmarks/generated/README.md
+ if ! git diff --cached --quiet; then
+ git commit -m "ci: update benchmark docs artifacts"
+ git push
+ fi
diff --git a/.github/workflows/build-documentation.yml b/.github/workflows/build-documentation.yml
index 577a4a1e..e3f3c370 100644
--- a/.github/workflows/build-documentation.yml
+++ b/.github/workflows/build-documentation.yml
@@ -8,7 +8,7 @@ on:
paths:
- 'docs/**'
- 'dev/mkdocs.yml'
- - '.readthedocs.yaml'
+ - 'dev/.readthedocs.yaml'
- 'dev/requirements-rtd.txt'
- 'ccbt/**'
workflow_dispatch:
@@ -147,7 +147,7 @@ jobs:
- name: Generate Bandit report
run: |
uv run python tests/scripts/ensure_bandit_dir.py
- uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks
+ uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks,ccbt/i18n/scripts,ccbt/i18n/locale_data
continue-on-error: true
- name: Ensure report files exist in documentation location
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 089c427a..c3880a54 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -77,7 +77,6 @@ jobs:
i18n:
name: i18n
runs-on: ubuntu-latest
- continue-on-error: true
environment: approval-required
permissions:
contents: read
@@ -104,10 +103,6 @@ jobs:
run: |
uv run python -m ccbt.i18n.extract ccbt ccbt/i18n/locales/en/LC_MESSAGES/ccbt.pot
- - name: Check string coverage
- run: |
- uv run python -m ccbt.i18n.scripts.check_string_coverage --source-dir ccbt --fail-on-gap
-
- name: Validate .po files
run: |
uv run python -m ccbt.i18n.scripts.validate_po
diff --git a/.github/workflows/generate-reports.yml b/.github/workflows/generate-reports.yml
index 54d810d9..04c669f9 100644
--- a/.github/workflows/generate-reports.yml
+++ b/.github/workflows/generate-reports.yml
@@ -46,7 +46,7 @@ jobs:
- name: Ensure bandit directory exists
run: uv run python tests/scripts/ensure_bandit_dir.py
- name: Run Bandit security scan
- run: uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks
+ run: uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks,ccbt/i18n/scripts,ccbt/i18n/locale_data
- name: Upload Bandit report artifact
uses: actions/upload-artifact@v4
with:
diff --git a/.github/workflows/i18n-manual.yml b/.github/workflows/i18n-manual.yml
index ecff2803..fa95ace1 100644
--- a/.github/workflows/i18n-manual.yml
+++ b/.github/workflows/i18n-manual.yml
@@ -1,4 +1,4 @@
-# Manual i18n pipeline: extract, update, validate, coverage, completeness, compile.
+# Manual i18n pipeline: extract, msgmerge, fill English, validate, completeness, compile.
# Run from Actions tab (workflow_dispatch) to validate translations on main or current branch.
name: i18n (manual)
@@ -35,14 +35,22 @@ jobs:
- name: Install dependencies
run: uv sync --dev
+ - name: Install gettext (msgmerge)
+ run: sudo apt-get update && sudo apt-get install -y gettext
+
- name: Extract translatable strings
run: uv run python -m ccbt.i18n.extract ccbt ccbt/i18n/locales/en/LC_MESSAGES/ccbt.pot
- - name: Update translation files
- run: uv run python -m ccbt.i18n.scripts.update_translations
+ - name: Merge template into locale catalogs (msgmerge)
+ run: |
+ set -euo pipefail
+ POT=ccbt/i18n/locales/en/LC_MESSAGES/ccbt.pot
+ for po in ccbt/i18n/locales/*/LC_MESSAGES/ccbt.po; do
+ msgmerge --update --backup=none --sort-output "$po" "$POT"
+ done
- - name: Check string coverage
- run: uv run python -m ccbt.i18n.scripts.check_string_coverage --source-dir ccbt --fail-on-gap
+ - name: Fill English catalog (msgstr = msgid for new entries)
+ run: uv run python -m ccbt.i18n.scripts.fill_english
- name: Validate .po files
run: uv run python -m ccbt.i18n.scripts.validate_po
@@ -58,4 +66,3 @@ jobs:
- name: Compile .mo files
run: uv run python -m ccbt.i18n.scripts.compile_all
- continue-on-error: true
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 3b4a3549..294e8a27 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -204,7 +204,11 @@ jobs:
- name: Build documentation
run: |
- uv run mkdocs build --strict -f dev/mkdocs.yml
+ mkdir -p site/reports/htmlcov
+ if [ ! -f site/reports/htmlcov/index.html ]; then
+ echo '
Coverage Report
Placeholder for mkdocs-coverage during release build.
' > site/reports/htmlcov/index.html
+ fi
+ MKDOCS_STRICT=true uv run python dev/build_docs_patched_clean.py
- name: Build package
run: |
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index 9ca061fa..13eaf2c2 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -41,7 +41,7 @@ jobs:
- name: Run Bandit security scan
run: |
- uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks
+ uv run bandit -r ccbt/ -f json -o docs/reports/bandit/bandit-report.json --severity-level medium -x tests,benchmarks,dev,dist,docs,htmlcov,site,.venv,.pre-commit-cache,.pre-commit-home,.pytest_cache,.ruff_cache,.hypothesis,.github,.ccbt,.cursor,.benchmarks,ccbt/i18n/scripts,ccbt/i18n/locale_data
- name: Upload Bandit report
if: always()
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 57a9d3d3..e4db7bcd 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -66,6 +66,10 @@ jobs:
- name: Install dependencies
run: |
uv sync --dev
+
+ - name: Prepare test report directories
+ run: |
+ mkdir -p site/reports
- name: Check for port conflicts
run: |
@@ -98,6 +102,8 @@ jobs:
- name: Run tests with coverage
shell: bash
+ env:
+ CCBT_TEST_DEBUG_LOG: /tmp/ccbt-test-debug.log
run: |
# Exclude compatibility tests from main test run (they run separately)
uv run pytest -c dev/pytest.ini tests/ \
diff --git a/.gitignore b/.gitignore
index e5d743fa..d7a675dd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,7 +11,7 @@ MagicMock
scripts
compatibility_tests/
lint_outputs/
-
+locales
# Pre-commit, pre-push, and benchmark outputs (CI force-adds when committing reports)
.pre-commit-cache/
.pre-commit-home/
@@ -26,6 +26,13 @@ benchmarks/output/
benchmarks/results/
benchmark_results/
docs/reports/
+!docs/
+!docs/en/
+!docs/en/reports/
+!docs/en/reports/benchmarks/
+!docs/en/reports/benchmarks/generated/
+!docs/en/reports/benchmarks/generated/*.md
+!docs/en/reports/benchmarks/generated/*.json
docs/reports/coverage/
docs/reports/bandit/
docs/reports/benchmarks/artifacts/
diff --git a/AzuriteConfig b/AzuriteConfig
deleted file mode 100644
index 296fcc2f..00000000
--- a/AzuriteConfig
+++ /dev/null
@@ -1 +0,0 @@
-{"instaceID":"7d61017f-125f-488f-b15d-143f1a2fc570"}
\ No newline at end of file
diff --git a/__azurite_db_table__.json b/__azurite_db_table__.json
deleted file mode 100644
index d0a1963b..00000000
--- a/__azurite_db_table__.json
+++ /dev/null
@@ -1 +0,0 @@
-{"filename":"c:\\Users\\MeMyself\\bittorrentclient\\__azurite_db_table__.json","collections":[{"name":"$TABLES_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{"account":{"name":"account","dirty":false,"values":[]},"table":{"name":"table","dirty":false,"values":[]}},"constraints":null,"uniqueNames":[],"transforms":{},"objType":"$TABLES_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]},{"name":"$SERVICES_COLLECTION$","data":[],"idIndex":null,"binaryIndices":{},"constraints":null,"uniqueNames":["accountName"],"transforms":{},"objType":"$SERVICES_COLLECTION$","dirty":false,"cachedIndex":null,"cachedBinaryIndex":null,"cachedData":null,"adaptiveBinaryIndices":true,"transactional":false,"cloneObjects":false,"cloneMethod":"parse-stringify","asyncListeners":false,"disableMeta":false,"disableChangesApi":true,"disableDeltaChangesApi":true,"autoupdate":false,"serializableIndices":true,"disableFreeze":true,"ttl":null,"maxId":0,"DynamicViews":[],"events":{"insert":[],"update":[],"pre-insert":[],"pre-update":[],"close":[],"flushbuffer":[],"error":[],"delete":[null],"warning":[null]},"changes":[],"dirtyIds":[]}],"databaseVersion":1.5,"engineVersion":1.5,"autosave":true,"autosaveInterval":5000,"autosaveHandle":null,"throttledSaves":true,"options":{"persistenceMethod":"fs","autosave":true,"autosaveInterval":5000,"serializationMethod":"normal","destructureDelimiter":"$<\n"},"persistenceMethod":"fs","persistenceAdapter":null,"verbose":false,"events":{"init":[null],"loaded":[],"flushChanges":[],"close":[],"changes":[],"warning":[]},"ENV":"NODEJS"}
\ No newline at end of file
diff --git a/ccbt.toml b/ccbt.toml
index 31af9674..72ccad0a 100644
--- a/ccbt.toml
+++ b/ccbt.toml
@@ -1,45 +1,91 @@
[network]
-max_global_peers = 600
-max_peers_per_torrent = 200
-max_connections_per_peer = 4
-pipeline_depth = 120
-block_size_kib = 64
+max_global_peers = 200
+max_peers_per_torrent = 50
+pipeline_depth = 16
+block_size_kib = 16
min_block_size_kib = 4
-max_block_size_kib = 128
-socket_rcvbuf_kib = 512
-socket_sndbuf_kib = 256
-tcp_nodelay = true
-connection_timeout = 30.0
-handshake_timeout = 10.0
-keep_alive_interval = 120.0
-peer_timeout = 60.0
-dht_timeout = 4.0
-listen_port = 6881
+max_block_size_kib = 64
+listen_port = 64122
listen_port_tcp = 64122
listen_port_udp = 64122
tracker_udp_port = 64123
-xet_port = 64126
xet_multicast_address = "239.255.255.250"
-xet_multicast_port = 64127
+xet_multicast_port = 6882
listen_interface = "0.0.0.0"
enable_ipv6 = true
enable_tcp = true
-enable_utp = true
+enable_utp = false
enable_encryption = true
+socket_rcvbuf_kib = 256
+socket_sndbuf_kib = 256
+tcp_nodelay = true
+max_connections_per_peer = 1
+announce_interval = 1800
+connection_timeout = 30.0
+handshake_timeout = 10.0
+keep_alive_interval = 120.0
+peer_timeout = 60.0
+peer_quality_probation_timeout = 60.0
+dht_timeout = 2.0
+handshake_adaptive_timeout_enabled = true
+handshake_timeout_desperation_min = 30.0
+handshake_timeout_desperation_max = 60.0
+handshake_timeout_normal_min = 15.0
+handshake_timeout_normal_max = 30.0
+handshake_timeout_healthy_min = 20.0
+handshake_timeout_healthy_max = 40.0
+metadata_exchange_timeout = 60.0
+metadata_piece_timeout = 15.0
+connection_health_check_interval = 30.0
+connection_validation_enabled = true
+connection_retry_max_attempts = 3
+connection_retry_backoff_base = 2.0
+connection_retry_backoff_max = 60.0
+peer_validation_enabled = true
+peer_validation_timeout = 5.0
+connection_state_validation_enabled = true
+connection_state_timeout = 120.0
+send_bitfield_after_metadata = true
+send_interested_after_metadata = true
+graceful_disconnect_enabled = true
+connection_cleanup_delay = 2.0
+max_concurrent_connection_attempts = 20
+connection_failure_threshold = 3
+connection_failure_backoff_base = 2.0
+connection_failure_backoff_max = 300.0
+enable_fail_fast_dht = true
+fail_fast_dht_timeout = 30.0
+global_down_kib = 0
+global_up_kib = 0
+per_peer_down_kib = 0
+per_peer_up_kib = 0
max_upload_slots = 4
+reciprocation_choked_peer_score_boost = 0.12
+reciprocation_remote_not_interested_boost = 0.06
+low_download_diversity_threshold = 1
+low_download_diversity_full_unchoke = true
+low_download_diversity_use_hysteresis = false
+low_download_diversity_exit_margin = 1
+low_download_diversity_max_peers = 0
+leech_heavy_swarm_total_upload_bps_threshold = 2048.0
+inbound_unknown_hash_warning_sample_interval = 32
+reciprocation_max_combined_boost = 0.25
+optimistic_unchoke_top_candidates = 3
+optimistic_unchoke_use_jitter = true
optimistic_unchoke_interval = 30.0
unchoke_interval = 10.0
+# Remote UNCHOKE stall recovery (see docs/en/network-troubleshooting.md)
+peer_choked_hard_timeout_seconds = 30.0
+peer_choked_anchor_timeout_seconds = 75.0
+peer_choked_solo_grace_seconds = 180.0
+peer_choked_solo_grace_zero_bytes_cap_seconds = 0.0
choking_upload_rate_weight = 0.6
choking_download_rate_weight = 0.4
choking_performance_score_weight = 0.2
peer_quality_performance_weight = 0.4
peer_quality_success_rate_weight = 0.2
peer_quality_source_weight = 0.2
-peer_quality_proximity_weight = 0.2
-global_down_kib = 0
-global_up_kib = 0
-per_peer_down_kib = 0
-per_peer_up_kib = 0
+peer_quality_proximity_weight = 0.05
tracker_timeout = 30.0
tracker_connect_timeout = 10.0
tracker_connection_limit = 50
@@ -48,8 +94,13 @@ dns_cache_ttl = 300
tracker_keepalive_timeout = 300.0
tracker_enable_dns_cache = true
tracker_dns_cache_ttl = 300
-connection_pool_max_connections = 400
+tracker_network_failure_quarantine_seconds = 90.0
+tracker_payload_failure_quarantine_seconds = 120.0
+tracker_dns_refused_escalation_streak = 5
+tracker_zero_active_batches_before_dht_short_circuit = 3
+connection_pool_max_connections = 150
connection_pool_max_idle_time = 300.0
+# On Windows the client may force warmup off at runtime to avoid WinError 121; this is the default intent.
connection_pool_warmup_enabled = true
connection_pool_warmup_count = 10
connection_pool_health_check_interval = 60.0
@@ -71,7 +122,7 @@ timeout_min_seconds = 5.0
timeout_max_seconds = 300.0
timeout_rtt_multiplier = 3.0
retry_exponential_backoff = true
-retry_base_delay = 1.0
+retry_base_delay = 10.0
retry_max_delay = 300.0
circuit_breaker_enabled = true
circuit_breaker_failure_threshold = 5
@@ -82,132 +133,129 @@ socket_max_buffer_kib = 65536
socket_enable_window_scaling = true
pipeline_adaptive_depth = true
pipeline_min_depth = 4
-pipeline_max_depth = 64
+pipeline_max_depth = 128
pipeline_enable_prioritization = true
pipeline_enable_coalescing = true
pipeline_coalesce_threshold_kib = 4
-max_concurrent_connection_attempts = 20
-connection_failure_threshold = 3
-connection_failure_backoff_base = 2.0
-connection_failure_backoff_max = 300.0
-enable_fail_fast_dht = true
-fail_fast_dht_timeout = 30.0
-
-[plugins]
-enable_plugins = true
-auto_load_plugins = true
-plugin_directories = []
[disk]
preallocate = "full"
-sparse_files = false
write_batch_kib = 64
write_buffer_kib = 1024
+write_batch_timeout_adaptive = true
+write_batch_timeout_ms = 5.0
+write_contiguous_threshold = 4096
+write_queue_priority = true
use_mmap = true
-mmap_cache_mb = 128
-mmap_cache_cleanup_interval = 30.0
+sparse_files = false
hash_workers = 4
+hash_queue_size = 100
hash_chunk_size = 65536
hash_batch_size = 4
-hash_queue_size = 100
+hash_workers_adaptive = true
+hash_chunk_size_adaptive = true
disk_workers = 2
disk_queue_size = 200
+disk_workers_adaptive = true
+disk_workers_min = 1
+disk_workers_max = 16
cache_size_mb = 256
+mmap_cache_mb = 128
+mmap_cache_cleanup_interval = 30.0
+mmap_cache_warmup = true
+mmap_cache_adaptive = true
direct_io = false
sync_writes = false
-read_ahead_kib = 128
+read_ahead_kib = 64
+read_ahead_adaptive = true
+read_ahead_max_kib = 1024
+read_prefetch_enabled = true
+read_parallel_segments = true
+read_buffer_pool_size = 10
enable_io_uring = false
-download_path = ""
-download_dir = "C:\\Users\\MeMyself\\Downloads"
+io_priority = "normal"
+io_schedule_by_lba = true
+nvme_queue_depth = 1024
+download_dir = "downloads"
+xet_enabled = false
+xet_chunk_min_size = 8192
+xet_chunk_max_size = 131072
+xet_chunk_target_size = 16384
+xet_deduplication_enabled = true
+enable_file_deduplication = true
+enable_data_aggregation = true
+enable_defrag_prevention = true
+xet_batch_size = 100
+defrag_check_interval = 3600.0
+xet_use_p2p_cas = true
+xet_compression_enabled = false
checkpoint_enabled = true
checkpoint_format = "both"
-checkpoint_dir = ""
checkpoint_interval = 30.0
checkpoint_on_piece = true
auto_resume = true
checkpoint_compression = true
+checkpoint_compression_algorithm = "zstd"
+checkpoint_incremental = true
+checkpoint_batch_interval = 5.0
+checkpoint_batch_pieces = 10
+checkpoint_deduplication = true
auto_delete_checkpoint_on_complete = true
-checkpoint_retention_days = 30
fast_resume_enabled = true
resume_save_interval = 30.0
resume_verify_on_load = true
resume_verify_pieces = 10
resume_data_format_version = 1
-
-[xet_sync]
-enable_xet = true
-check_interval = 5.0
-default_sync_mode = "best_effort"
-enable_git_versioning = true
-enable_lpd = true
-enable_gossip = true
-gossip_fanout = 3
-gossip_interval = 5.0
-flooding_ttl = 10
-flooding_priority_threshold = 100
-consensus_algorithm = "simple"
-raft_election_timeout = 1.0
-raft_heartbeat_interval = 0.1
-enable_byzantine_fault_tolerance = false
-byzantine_fault_threshold = 0.33
-weighted_voting = false
-auto_elect_source = false
-source_election_interval = 300.0
-conflict_resolution_strategy = "last_write_wins"
-git_auto_commit = true
-consensus_threshold = 0.5
-max_update_queue_size = 100
-allowlist_encryption_key = ""
+checkpoint_retention_days = 30
[strategy]
-piece_selection = "sequential"
+piece_selection = "rarest_first"
endgame_duplicates = 2
endgame_threshold = 0.95
-streaming_mode = true
+pipeline_capacity = 4
+streaming_mode = false
rarest_first_threshold = 0.1
-sequential_window = 50
+sequential_window = 10
sequential_priority_files = []
sequential_fallback_threshold = 0.1
-pipeline_capacity = 16
-first_piece_priority = true
-last_piece_priority = false
bandwidth_weighted_rarest_weight = 0.7
progressive_rarest_transition_threshold = 0.5
adaptive_hybrid_phase_detection_window = 10
[discovery]
enable_dht = true
-dht_port = 64124
-dht_bootstrap_nodes = [ "router.bittorrent.com:6881", "dht.transmissionbt.com:6881", "router.utorrent.com:6881", "dht.libtorrent.org:25401", "dht.aelitis.com:6881", "router.silotis.us:6881", "router.bitcomet.com:6881",]
-dht_enable_ipv6 = true
-dht_prefer_ipv6 = true
-dht_ipv6_bootstrap_nodes = []
-dht_readonly_mode = false
-dht_enable_multiaddress = true
-dht_max_addresses_per_node = 4
-dht_enable_storage = false
-dht_storage_ttl = 3600
-dht_max_storage_size = 1000
-dht_enable_indexing = true
-dht_index_samples_per_key = 8
-xet_chunk_query_batch_size = 50
-xet_chunk_query_max_concurrent = 50
-discovery_cache_ttl = 60.0
+min_peers_before_dht = 10
enable_pex = true
-pex_interval = 30.0
-enable_http_trackers = true
enable_udp_trackers = true
-tracker_announce_interval = 1800.0
-tracker_scrape_interval = 3600.0
+enable_http_trackers = true
+dht_port = 64120
+dht_bootstrap_nodes = [ "router.bittorrent.com:6881", "dht.transmissionbt.com:6881", "router.utorrent.com:6881", "dht.libtorrent.org:25401", "dht.aelitis.com:6881", "router.silotis.us:6881", "router.bitcomet.com:6881",]
+dht_adaptive_interval_enabled = true
+dht_base_refresh_interval = 600.0
+dht_adaptive_interval_min = 60.0
+dht_adaptive_interval_max = 1920.0
+dht_quality_tracking_enabled = true
+dht_quality_response_time_window = 10
+dht_adaptive_timeout_enabled = true
+dht_timeout_desperation_min = 30.0
+dht_timeout_desperation_max = 60.0
+dht_timeout_normal_min = 5.0
+dht_timeout_normal_max = 15.0
+dht_timeout_healthy_min = 10.0
+dht_timeout_healthy_max = 30.0
+tracker_announce_interval = 60.0
+tracker_scrape_interval = 45.0
+tracker_adaptive_interval_enabled = true
+tracker_adaptive_interval_min = 20.0
+tracker_adaptive_interval_max = 3600.0
+tracker_base_announce_interval = 1800.0
+tracker_peer_count_weight = 0.3
+tracker_performance_weight = 0.4
tracker_auto_scrape = true
default_trackers = [ "https://tracker.opentrackr.org:443/announce", "https://tracker.torrent.eu.org:443/announce", "https://tracker.openbittorrent.com:443/announce", "http://tracker.opentrackr.org:1337/announce", "http://tracker.openbittorrent.com:80/announce", "udp://tracker.opentrackr.org:1337/announce", "udp://tracker.openbittorrent.com:80/announce",]
-handshake_adaptive_timeout_enabled = true
-handshake_timeout_desperation_min = 30.0
-handshake_timeout_desperation_max = 60.0
-handshake_timeout_normal_min = 15.0
-handshake_timeout_normal_max = 30.0
-handshake_timeout_healthy_min = 20.0
-handshake_timeout_healthy_max = 40.0
+pex_interval = 60.0
+xet_chunk_query_batch_size = 50
+xet_chunk_query_max_concurrent = 50
aggressive_initial_discovery = true
aggressive_initial_tracker_interval = 30.0
aggressive_initial_dht_interval = 30.0
@@ -222,26 +270,30 @@ dht_normal_max_depth = 12
dht_aggressive_alpha = 8
dht_aggressive_k = 32
dht_aggressive_max_depth = 15
-dht_adaptive_timeout_enabled = true
-dht_timeout_desperation_min = 30.0
-dht_timeout_desperation_max = 60.0
-dht_timeout_normal_min = 5.0
-dht_timeout_normal_max = 15.0
-dht_timeout_healthy_min = 10.0
-dht_timeout_healthy_max = 30.0
+discovery_cache_ttl = 60.0
strict_private_mode = true
+magnet_respect_indices = true
+dht_enable_ipv6 = true
+dht_prefer_ipv6 = false
+dht_ipv6_bootstrap_nodes = []
+dht_readonly_mode = false
+dht_enable_multiaddress = true
+dht_max_addresses_per_node = 4
+dht_enable_storage = false
+dht_storage_ttl = 3600
+dht_max_storage_size = 1000
+dht_enable_indexing = true
+dht_index_samples_per_key = 8
[observability]
log_level = "INFO"
-log_file = ""
-log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
+enable_metrics = true
+metrics_port = 64125
+enable_peer_tracing = false
structured_logging = true
log_correlation_id = true
-enable_metrics = true
-metrics_port = 9090
+log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
metrics_interval = 5.0
-enable_peer_tracing = false
-trace_file = ""
alerts_rules_path = ".ccbt/alerts.json"
event_bus_max_queue_size = 10000
event_bus_batch_size = 50
@@ -262,6 +314,7 @@ per_peer_up_kib = 0
scheduler_slice_ms = 100
[security]
+peer_quality_threshold = 0.3
enable_encryption = true
encryption_mode = "preferred"
encryption_dh_key_size = 768
@@ -271,18 +324,13 @@ encryption_allow_plain_fallback = true
validate_peers = true
rate_limit_enabled = true
max_connections_per_peer = 1
-peer_quality_threshold = 0.3
[proxy]
enable_proxy = false
proxy_type = "http"
-proxy_host = ""
-proxy_port = 0
-proxy_username = ""
-proxy_password = ""
proxy_for_trackers = true
proxy_for_peers = false
-proxy_for_webseeds = false
+proxy_for_webseeds = true
proxy_bypass_list = []
[ml]
@@ -296,7 +344,7 @@ port = 9090
refresh_interval = 1.0
default_view = "overview"
enable_grafana_export = false
-terminal_refresh_interval = 2.0
+terminal_refresh_interval = 1.0
terminal_daemon_startup_timeout = 90.0
terminal_daemon_initial_wait = 5.0
terminal_daemon_retry_delay = 0.5
@@ -304,6 +352,9 @@ terminal_daemon_check_interval = 1.0
terminal_connection_timeout = 10.0
terminal_connection_check_interval = 0.5
+[ui]
+locale = "en"
+
[queue]
max_active_torrents = 5
max_active_downloading = 3
@@ -311,9 +362,8 @@ max_active_seeding = 2
default_priority = "normal"
bandwidth_allocation_mode = "proportional"
auto_manage_queue = true
-
-[ui]
-locale = "en"
+save_queue_state = true
+queue_state_save_interval = 30.0
[nat]
enable_nat_pmp = true
@@ -327,15 +377,88 @@ map_dht_port = true
map_xet_port = true
map_xet_multicast_port = false
-[daemon]
-ipc_host = "127.0.0.1"
-ipc_port = 64130
-api_key = "63db67e447a552c3541457967f7b3f4bedbcda97b57fa750f6e35831553043d1"
+[ipfs]
+api_url = "http://127.0.0.1:5001"
+gateway_urls = [ "https://ipfs.io/ipfs/", "https://gateway.pinata.cloud/ipfs/", "https://cloudflare-ipfs.com/ipfs/",]
+enable_pinning = false
+connection_timeout = 30
+request_timeout = 30
+enable_dht = true
+discovery_cache_ttl = 300
[webtorrent]
-enable_webtorrent = true
+enable_webtorrent = false
webtorrent_port = 64126
-webtorrent_host = "127.0.0.1"
+webtorrent_host = "localhost"
+webtorrent_stun_servers = [ "stun:stun.l.google.com:19302",]
+webtorrent_turn_servers = []
+webtorrent_max_connections = 100
+webtorrent_connection_timeout = 30.0
+
+[media]
+enable_media_streaming = true
+bind_host = "127.0.0.1"
+default_port = 0
+startup_buffer_seconds = 8.0
+request_wait_timeout_seconds = 5.0
+assumed_bitrate_bytes_per_second = 1000000
+stream_chunk_size_kib = 256
+token_ttl_seconds = 3600.0
+enable_inline_media_preview = false
+inline_media_preview_mode = "disabled"
+
+[per_torrent_defaults]
+
+[xet_sync]
+enable_xet = false
+check_interval = 5.0
+default_sync_mode = "best_effort"
+enable_git_versioning = true
+auth_scope = "strict_workspace_auth"
+hash_algorithm_policy = "negotiate"
+require_signed_metadata = true
+enable_lpd = true
+enable_gossip = true
+enable_dht = true
+enable_tracker = true
+enable_pex = true
+enable_catalog = true
+enable_bloom = true
+enable_multicast = true
+enable_flooding = true
+gossip_fanout = 3
+gossip_interval = 5.0
+flooding_ttl = 10
+flooding_priority_threshold = 100
+consensus_algorithm = "simple"
+raft_election_timeout = 1.0
+raft_heartbeat_interval = 0.1
+enable_byzantine_fault_tolerance = false
+byzantine_fault_threshold = 0.33
+weighted_voting = false
+auto_elect_source = false
+source_election_interval = 300.0
+conflict_resolution_strategy = "last_write_wins"
+git_auto_commit = false
+consensus_threshold = 0.5
+max_update_queue_size = 100
+
+[plugins]
+enable_plugins = true
+auto_load_plugins = true
+plugin_directories = []
+
+[optimization]
+profile = "balanced"
+speed_aggressive_peer_recycling = true
+efficiency_connection_limit_multiplier = 0.8
+low_resource_max_connections = 20
+enable_adaptive_intervals = true
+enable_performance_based_recycling = true
+enable_bandwidth_aware_scheduling = true
+
+[daemon]
+api_key = "34061d54117bf7c055601182c1d0c01716ef2b7be84fede453bf4ac80c12d1c4"
[network.utp]
prefer_over_tcp = false
@@ -349,39 +472,29 @@ ack_interval = 0.2
retransmit_timeout_factor = 5.0
max_retransmits = 15
+[network.webtorrent]
+enable_webtorrent = false
+webtorrent_port = 64126
+webtorrent_host = "localhost"
+webtorrent_stun_servers = [ "stun:stun.l.google.com:19302",]
+webtorrent_turn_servers = []
+webtorrent_max_connections = 100
+webtorrent_connection_timeout = 30.0
+
[network.protocol_v2]
enable_protocol_v2 = true
prefer_protocol_v2 = false
support_hybrid = true
v2_handshake_timeout = 30.0
-[plugins.metrics]
-enable_metrics_plugin = true
-max_metrics = 10000
-enable_event_metrics = true
-metrics_retention_seconds = 3600
-enable_aggregation = true
-aggregation_window = 60.0
-
[disk.attributes]
preserve_attributes = true
skip_padding_files = true
-verify_file_sha1 = true
+verify_file_sha1 = false
apply_symlinks = true
apply_executable_bit = true
apply_hidden_attr = true
-[disk.xet]
-xet_enabled = true
-xet_chunk_min_size = 8192
-xet_chunk_max_size = 131072
-xet_chunk_target_size = 16384
-xet_deduplication_enabled = true
-xet_cache_db_path = ""
-xet_chunk_store_path = ""
-xet_use_p2p_cas = true
-xet_compression_enabled = false
-
[security.ip_filter]
enable_ip_filter = false
filter_mode = "block"
@@ -394,20 +507,50 @@ filter_log_blocked = true
[security.blacklist]
enable_persistence = true
blacklist_file = "~/.ccbt/security/blacklist.json"
-auto_update_enabled = true
+auto_update_enabled = false
auto_update_interval = 3600.0
auto_update_sources = []
-default_expiration_hours = 24
[security.ssl]
enable_ssl_trackers = true
enable_ssl_peers = false
ssl_verify_certificates = true
-ssl_ca_certificates = ""
-ssl_client_certificate = ""
-ssl_client_key = ""
-ssl_protocol_version = "TLSv1.2"
+ssl_protocol_version = "TLSv1.3"
+ssl_cipher_suites = []
ssl_allow_insecure_peers = true
+ssl_extension_enabled = true
+ssl_extension_opportunistic = true
+ssl_extension_timeout = 5.0
+
+[security.authenticated_swarms]
+mode = "off"
+discovery_mode = "trackers_only"
+discovery_strict_for_strict_mode = true
+trusted_swarm_ids = []
+strict_ltep_handshake_timeout_s = 30.0
+fail_closed_on_parse_errors = false
+trust_store_refresh_interval_s = 60.0
+revocation_refresh_interval_s = 300.0
+
+[queue.priority_weights]
+maximum = 5.0
+high = 2.0
+normal = 1.0
+low = 0.5
+
+[queue.priority_bandwidth_kib]
+maximum = 1000
+high = 500
+normal = 250
+low = 100
+
+[plugins.metrics]
+enable_metrics_plugin = true
+max_metrics = 10000
+enable_event_metrics = true
+metrics_retention_seconds = 3600
+enable_aggregation = true
+aggregation_window = 60.0
[security.blacklist.local_source]
enabled = true
diff --git a/ccbt/__init__.py b/ccbt/__init__.py
index 0e3c680e..3d8efb97 100644
--- a/ccbt/__init__.py
+++ b/ccbt/__init__.py
@@ -9,6 +9,7 @@
# This avoids RuntimeError: There is no current event loop in thread 'MainThread'.
try:
import asyncio
+ import warnings
class _SafeEventLoopPolicy(asyncio.AbstractEventLoopPolicy):
"""Wrapper policy that ensures a loop exists when requested."""
@@ -20,16 +21,19 @@ def get_event_loop(self): # type: ignore[override]
try:
return asyncio.get_running_loop()
except RuntimeError:
- # No running loop - try to get one from base policy first
- # This allows pytest-asyncio and other tools to manage event loops
- try:
- return self._base.get_event_loop()
- except RuntimeError:
- # Base policy also can't provide a loop - create new one
- # This is the fallback for user code that needs a loop
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- return loop
+ # No running loop - try to get thread-default loop from base policy first.
+ # Python 3.12+ deprecates asyncio.get_event_loop() when no loop is set;
+ # suppress only for this delegation so we still return a pytest-managed loop.
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", DeprecationWarning)
+ try:
+ return self._base.get_event_loop()
+ except RuntimeError:
+ # Base policy also can't provide a loop - create new one
+ # This is the fallback for user code that needs a loop
+ loop = asyncio.new_event_loop()
+ asyncio.set_event_loop(loop)
+ return loop
def set_event_loop(self, loop): # type: ignore[override]
return self._base.set_event_loop(loop)
@@ -43,7 +47,7 @@ def get_running_loop(self): # type: ignore[override]
# Child watcher methods (posix); delegate if present
def get_child_watcher(self): # type: ignore[override]
- def _raise_not_implemented(): # pragma: no cover - Nested function definition, only executed if base lacks method (platform-specific)
+ def _raise_not_implemented(): # pragma: no cover - Nested function definition, only executed if base lacks method (platform-specific):
raise NotImplementedError # pragma: no cover - NotImplementedError path, tested via test_get_child_watcher_no_base
if hasattr(
@@ -53,7 +57,7 @@ def _raise_not_implemented(): # pragma: no cover - Nested function definition,
return _raise_not_implemented() # pragma: no cover - Same context
def set_child_watcher(self, watcher): # type: ignore[override]
- def _raise_not_implemented(): # pragma: no cover - Nested function definition, only executed if base lacks method (platform-specific)
+ def _raise_not_implemented(): # pragma: no cover - Nested function definition, only executed if base lacks method (platform-specific):
raise NotImplementedError # pragma: no cover - NotImplementedError path, tested via test_set_child_watcher_no_base
if hasattr(
@@ -64,7 +68,7 @@ def _raise_not_implemented(): # pragma: no cover - Nested function definition,
) # pragma: no cover - Same context
return _raise_not_implemented() # pragma: no cover - Same context
- # CRITICAL FIX: On Windows, use SelectorEventLoop instead of ProactorEventLoop
+ # Note: On Windows, use SelectorEventLoop instead of ProactorEventLoop
# ProactorEventLoop has known bugs with UDP sockets (WinError 10022)
# This must be set BEFORE wrapping with _SafeEventLoopPolicy
import sys
@@ -96,7 +100,9 @@ def _raise_not_implemented(): # pragma: no cover - Nested function definition,
): # pragma: no cover - Exception handling during policy setup, defensive fallback
# As a fallback, ensure a loop is set at import time
try:
- asyncio.get_event_loop() # pragma: no cover - Same context
+ with warnings.catch_warnings():
+ warnings.simplefilter("ignore", DeprecationWarning)
+ asyncio.get_event_loop() # pragma: no cover - Same context
except RuntimeError: # pragma: no cover - Same context
loop = asyncio.new_event_loop() # pragma: no cover - Same context
asyncio.set_event_loop(loop) # pragma: no cover - Same context
@@ -192,64 +198,7 @@ def _raise_not_implemented(): # pragma: no cover - Nested function definition,
]
-# Lazy attribute access to prefer submodules over similarly named attributes
-def __getattr__(
- name: str,
-): # pragma: no cover - import-time plumbing, tested via test_getattr_async_main
- if name == "async_main":
- import importlib
-
- return importlib.import_module("ccbt.async_main")
+# Lazy attribute access for undefined attributes
+def __getattr__(name: str): # pragma: no cover - import-time plumbing
msg = f"module '{__name__}' has no attribute '{name}'"
raise AttributeError(msg)
-
-
-# Ensure attribute binding prefers submodule even in long-lived interpreters
-try: # pragma: no cover - import-time plumbing, tested via module imports
- import importlib as _importlib
-
- async_main = _importlib.import_module(
- "ccbt.async_main"
- ) # pragma: no cover - Same context
-except Exception: # pragma: no cover - Exception handling during import, defensive
- pass # pragma: no cover - Same context
-
-# Backward compat: if async_main was imported as a function elsewhere, attach
-# commonly patched attributes so patch('ccbt.async_main.X') works.
-try: # pragma: no cover - import-time plumbing, backward compatibility setup
- import types as _types
-
- if isinstance(
- globals().get("async_main"), _types.FunctionType
- ): # pragma: no cover - Edge case: async_main as function, difficult to simulate
- import ccbt.session.async_main as _am # pragma: no cover - Same context
- from ccbt.config.config import (
- get_config as _get_config, # pragma: no cover - Same context
- )
- from ccbt.core.magnet import (
- build_minimal_torrent_data as _build_min,
- ) # pragma: no cover - Same context
- from ccbt.core.magnet import (
- parse_magnet as _parse_magnet,
- ) # pragma: no cover - Same context
- from ccbt.peer import (
- AsyncPeerConnectionManager as _APCM, # noqa: N814
- ) # pragma: no cover - Same context
- from ccbt.piece.async_piece_manager import (
- AsyncPieceManager as _APM, # noqa: N814
- ) # pragma: no cover - Same context
-
- async_main.get_config = _get_config # pragma: no cover - Same context
- async_main.AsyncPeerConnectionManager = _APCM # pragma: no cover - Same context
- async_main.AsyncPieceManager = _APM # pragma: no cover - Same context
- async_main.parse_magnet = _parse_magnet # pragma: no cover - Same context
- async_main.build_minimal_torrent_data = (
- _build_min # pragma: no cover - Same context
- )
- async_main.AsyncDownloadManager = (
- _am.AsyncDownloadManager
- ) # pragma: no cover - Same context
-except (
- Exception
-): # pragma: no cover - Exception handling during backward compat setup, defensive
- pass # pragma: no cover - Same context
diff --git a/ccbt/__main__.py b/ccbt/__main__.py
index 4f56fc6f..fce012b5 100644
--- a/ccbt/__main__.py
+++ b/ccbt/__main__.py
@@ -46,27 +46,32 @@ def main():
parser = argparse.ArgumentParser(description="ccBitTorrent - A BitTorrent client")
parser.add_argument("torrent", help="Path to torrent file, URL, or magnet URI")
parser.add_argument(
+ "-p",
"--port",
type=int,
default=6881,
help="Port to listen on (default: 6881)",
)
parser.add_argument(
+ "-m",
"--magnet",
action="store_true",
help="Treat input as a magnet URI",
)
parser.add_argument(
+ "-d",
"--daemon",
action="store_true",
help="Run long-lived multi-torrent session",
)
parser.add_argument(
+ "-a",
"--add",
action="append",
help="Add a torrent or magnet (repeatable)",
)
parser.add_argument(
+ "-s",
"--status",
action="store_true",
help="Show status and exit (daemon mode)",
diff --git a/ccbt/async_main.py b/ccbt/async_main.py
deleted file mode 100644
index 43028ee8..00000000
--- a/ccbt/async_main.py
+++ /dev/null
@@ -1,32 +0,0 @@
-"""Top-level async_main shim for tests and CLI compatibility.
-
-Re-export everything from ccbt.session.async_main so tests can patch
-ccbt.async_main.* symbols and have them affect the actual module.
-"""
-
-from __future__ import annotations
-
-# Explicit re-exports for commonly patched symbols to ensure they're accessible
-from ccbt.config.config import get_config
-from ccbt.core.magnet import build_minimal_torrent_data, parse_magnet
-from ccbt.peer import AsyncPeerConnectionManager
-from ccbt.piece.async_piece_manager import AsyncPieceManager
-
-# Re-export download helpers from canonical location
-from ccbt.session.download_manager import (
- AsyncDownloadManager,
- download_magnet,
- download_torrent,
-)
-
-# Ensure these are in the module namespace for patching
-__all__ = [
- "AsyncDownloadManager",
- "AsyncPeerConnectionManager",
- "AsyncPieceManager",
- "build_minimal_torrent_data",
- "download_magnet",
- "download_torrent",
- "get_config",
- "parse_magnet",
-]
diff --git a/ccbt/cli/advanced_commands.py b/ccbt/cli/advanced_commands.py
index f85576ca..b9c0ff6f 100644
--- a/ccbt/cli/advanced_commands.py
+++ b/ccbt/cli/advanced_commands.py
@@ -18,6 +18,7 @@
from rich.prompt import Confirm
from rich.table import Table
+from ccbt.cli.ssl_posture import is_strict_ssl_posture
from ccbt.config.config import get_config
from ccbt.config.config_capabilities import SystemCapabilities
from ccbt.i18n import _
@@ -216,10 +217,11 @@ async def _quick_disk_benchmark() -> dict:
@click.command("performance")
-@click.option("--analyze", is_flag=True, help="Analyze current performance")
-@click.option("--optimize", is_flag=True, help="Apply performance optimizations")
+@click.option("--analyze", "-a", is_flag=True, help="Analyze current performance")
+@click.option("--optimize", "-o", is_flag=True, help="Apply performance optimizations")
@click.option(
"--preset",
+ "-p",
type=click.Choice(
[
OptimizationPreset.PERFORMANCE,
@@ -232,17 +234,19 @@ async def _quick_disk_benchmark() -> dict:
)
@click.option(
"--save",
+ "-s",
is_flag=True,
help="Save optimizations to config file (requires --optimize)",
)
@click.option(
"--config-file",
+ "-c",
type=click.Path(),
default=None,
help="Config file path (defaults to ccbt.toml)",
)
-@click.option("--benchmark", is_flag=True, help="Run performance benchmarks")
-@click.option("--profile", is_flag=True, help="Enable performance profiling")
+@click.option("--benchmark", "-b", is_flag=True, help="Run performance benchmarks")
+@click.option("--profile", "-P", is_flag=True, help="Enable performance profiling")
def performance(
analyze: bool,
optimize: bool,
@@ -383,14 +387,21 @@ def performance(
@click.command("security")
-@click.option("--scan", is_flag=True, help="Scan for security issues")
-@click.option("--validate", is_flag=True, help="Validate peer connections")
-@click.option("--encrypt", is_flag=True, help="Enable encryption")
-@click.option("--rate-limit", is_flag=True, help="Enable rate limiting")
-def security(scan: bool, validate: bool, encrypt: bool, rate_limit: bool) -> None:
+@click.option("--scan", "-s", is_flag=True, help="Scan for security issues")
+@click.option("--validate", "-v", is_flag=True, help="Validate peer connections")
+@click.option("--encrypt", "-e", is_flag=True, help="Enable encryption")
+@click.option("--rate-limit", "-r", is_flag=True, help="Enable rate limiting")
+@click.option(
+ "--swarm-auth", "-w", is_flag=True, help="Show authenticated swarms settings"
+)
+def security(
+ scan: bool, validate: bool, encrypt: bool, rate_limit: bool, swarm_auth: bool
+) -> None:
"""Security management and validation."""
console = Console()
cfg = get_config()
+ ssl_cfg = cfg.security.ssl
+ strict_ssl_posture = is_strict_ssl_posture(ssl_cfg)
if scan:
console.print(_("[green]Performing basic configuration scan...[/green]"))
issues = []
@@ -421,16 +432,44 @@ def security(scan: bool, validate: bool, encrypt: bool, rate_limit: bool) -> Non
"[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]"
),
)
- if not any([scan, validate, encrypt, rate_limit]):
+ if strict_ssl_posture:
+ console.print(
+ _(
+ "[yellow]Warning: SSL certificate verification is disabled while SSL is used"
+ " in strict mode[/yellow]"
+ ),
+ )
+ if swarm_auth:
+ auth_cfg = getattr(cfg.security, "authenticated_swarms", None)
+ if auth_cfg is None:
+ console.print(_("[yellow]Authenticated swarms not configured[/yellow]"))
+ else:
+ table = Table(title="Authenticated Swarms", show_header=True)
+ table.add_column("Setting", style="cyan")
+ table.add_column("Value", style="green")
+ table.add_row("Mode", str(getattr(auth_cfg, "mode", "off")))
+ table.add_row(
+ "Discovery mode",
+ str(getattr(auth_cfg, "discovery_mode", "trackers_only")),
+ )
+ table.add_row(
+ "Discovery strict for strict mode",
+ str(bool(getattr(auth_cfg, "discovery_strict_for_strict_mode", False))),
+ )
+ trusted_ids = getattr(auth_cfg, "trusted_swarm_ids", [])
+ trusted_display = ", ".join(trusted_ids) if trusted_ids else "none"
+ table.add_row("Trusted swarm IDs", trusted_display)
+ console.print(table)
+ if not any([scan, validate, encrypt, rate_limit, swarm_auth]):
console.print(_("[yellow]No security action specified[/yellow]"))
@click.command("recover")
@click.argument("info_hash")
-@click.option("--repair", is_flag=True, help="Attempt to repair corrupted data")
-@click.option("--verify", is_flag=True, help="Verify data integrity")
-@click.option("--rehash", is_flag=True, help="Rehash all pieces")
-@click.option("--force", is_flag=True, help="Force recovery even if risky")
+@click.option("--repair", "-r", is_flag=True, help="Attempt to repair corrupted data")
+@click.option("--verify", "-v", is_flag=True, help="Verify data integrity")
+@click.option("--rehash", "-H", is_flag=True, help="Rehash all pieces")
+@click.option("--force", "-f", is_flag=True, help="Force recovery even if risky")
def recover(
info_hash: str,
repair: bool,
@@ -491,63 +530,65 @@ async def disk_detect(ctx): # noqa: ARG001
write_cache = capabilities.detect_write_cache(download_path)
# Display results
- table = Table(title="Storage Device Detection")
- table.add_column("Property", style="cyan")
- table.add_column("Value", style="green")
+ table = Table(title=_("Storage Device Detection"))
+ table.add_column(_("Property"), style="cyan")
+ table.add_column(_("Value"), style="green")
- table.add_row("Storage Type", storage_type.upper())
- table.add_row("Speed Category", storage_speed.get("speed_category", "unknown"))
+ table.add_row(_("Storage Type"), storage_type.upper())
+ table.add_row(
+ _("Speed Category"), storage_speed.get("speed_category", _("Unknown"))
+ )
table.add_row(
- "Estimated Read Speed",
+ _("Estimated Read Speed"),
f"{storage_speed.get('estimated_read_mbps', 0):.0f} MB/s",
)
table.add_row(
- "Estimated Write Speed",
+ _("Estimated Write Speed"),
f"{storage_speed.get('estimated_write_mbps', 0):.0f} MB/s",
)
- table.add_row("Write-Back Cache", "Enabled" if write_cache else "Disabled")
+ table.add_row(_("Write-Back Cache"), _("Enabled") if write_cache else _("Disabled"))
# Show recommendations
console.print("\n")
- rec_table = Table(title="Recommended Settings")
- rec_table.add_column("Setting", style="cyan")
- rec_table.add_column("Recommended Value", style="green")
- rec_table.add_column("Current Value", style="yellow")
+ rec_table = Table(title=_("Recommended Settings"))
+ rec_table.add_column(_("Setting"), style="cyan")
+ rec_table.add_column(_("Recommended Value"), style="green")
+ rec_table.add_column(_("Current Value"), style="yellow")
if storage_type == "nvme":
rec_table.add_row(
- "Write Batch Timeout",
- "0.1 ms (adaptive)",
+ _("Write Batch Timeout"),
+ _("0.1 ms (adaptive)"),
f"{config.disk.write_batch_timeout_ms} ms",
)
- rec_table.add_row("Disk Workers", "4-8", str(config.disk.disk_workers))
+ rec_table.add_row(_("Disk Workers"), _("4-8"), str(config.disk.disk_workers))
rec_table.add_row(
- "Hash Chunk Size",
- "1 MB (adaptive)",
+ _("Hash Chunk Size"),
+ _("1 MB (adaptive)"),
f"{config.disk.hash_chunk_size // 1024} KB",
)
elif storage_type == "ssd":
rec_table.add_row(
- "Write Batch Timeout",
- "5 ms (adaptive)",
+ _("Write Batch Timeout"),
+ _("5 ms (adaptive)"),
f"{config.disk.write_batch_timeout_ms} ms",
)
- rec_table.add_row("Disk Workers", "2-4", str(config.disk.disk_workers))
+ rec_table.add_row(_("Disk Workers"), _("2-4"), str(config.disk.disk_workers))
rec_table.add_row(
- "Hash Chunk Size",
- "512 KB (adaptive)",
+ _("Hash Chunk Size"),
+ _("512 KB (adaptive)"),
f"{config.disk.hash_chunk_size // 1024} KB",
)
else: # hdd
rec_table.add_row(
- "Write Batch Timeout",
- "50 ms (adaptive)",
+ _("Write Batch Timeout"),
+ _("50 ms (adaptive)"),
f"{config.disk.write_batch_timeout_ms} ms",
)
- rec_table.add_row("Disk Workers", "1-2", str(config.disk.disk_workers))
+ rec_table.add_row(_("Disk Workers"), _("1-2"), str(config.disk.disk_workers))
rec_table.add_row(
- "Hash Chunk Size",
- "64 KB (adaptive)",
+ _("Hash Chunk Size"),
+ _("64 KB (adaptive)"),
f"{config.disk.hash_chunk_size // 1024} KB",
)
@@ -647,16 +688,23 @@ async def disk_stats(ctx): # noqa: ARG001
@click.command("test")
-@click.option("--unit", is_flag=True, help="Run unit tests")
-@click.option("--integration", is_flag=True, help="Run integration tests")
+@click.option("--unit", "-u", is_flag=True, help="Run unit tests")
+@click.option("--integration", "-i", is_flag=True, help="Run integration tests")
@click.option(
"--performance",
+ "-p",
"performance_test",
is_flag=True,
help="Run performance tests",
)
-@click.option("--security", "security_test", is_flag=True, help="Run security tests")
-@click.option("--coverage", is_flag=True, help="Generate coverage report")
+@click.option(
+ "--security",
+ "-s",
+ "security_test",
+ is_flag=True,
+ help="Run security tests",
+)
+@click.option("--coverage", "-c", is_flag=True, help="Generate coverage report")
def test(
unit: bool,
integration: bool,
diff --git a/ccbt/cli/auth_commands.py b/ccbt/cli/auth_commands.py
new file mode 100644
index 00000000..b654cae7
--- /dev/null
+++ b/ccbt/cli/auth_commands.py
@@ -0,0 +1,263 @@
+"""CLI commands for authenticated swarm security configuration."""
+
+from __future__ import annotations
+
+from typing import Any, Iterable, Optional
+
+import click
+from rich.console import Console
+from rich.table import Table
+
+from ccbt.cli.ssl_commands import _should_skip_project_local_write
+from ccbt.config.config import get_config, init_config
+from ccbt.i18n import _
+
+console = Console()
+
+
+def _normalize_discovery_mode(value: str) -> str:
+ """Normalize discovery mode input to underscore style."""
+ return str(value).strip().lower().replace("-", "_")
+
+
+def _normalize_bool(value: str) -> bool:
+ """Normalize string bool-like values."""
+ normalized = str(value).strip().lower()
+ return normalized in {"1", "true", "yes", "on"}
+
+
+def _load_auth_config(ctx: Optional[click.Context]) -> tuple[Any, Any]:
+ """Return config manager and authenticated swarm config block."""
+ if ctx is None:
+ config_manager = init_config()
+ else:
+ try:
+ from ccbt.cli.main import _get_config_from_context
+
+ config_manager = _get_config_from_context(ctx)
+ except Exception:
+ config_manager = init_config()
+
+ config = config_manager.config
+ security = getattr(config, "security", None)
+ if security is None:
+ msg = "No security configuration"
+ raise RuntimeError(msg)
+ auth_cfg = getattr(security, "authenticated_swarms", None)
+ if auth_cfg is None:
+ msg = "No authenticated swarms configuration"
+ raise RuntimeError(msg)
+ return config_manager, auth_cfg
+
+
+def _persist_auth_config(config_manager: Any, message: str) -> None:
+ """Persist config for auth changes with project-local safety behavior."""
+ if config_manager.config_file:
+ if _should_skip_project_local_write(config_manager.config_file):
+ console.print(
+ _(
+ "[yellow]Authenticated swarm setting updated "
+ "(test mode, write skipped)[/yellow]"
+ )
+ )
+ return
+ config_toml = config_manager.export(fmt="toml")
+ config_manager.config_file.write_text(config_toml, encoding="utf-8")
+ console.print(
+ _("[green]{message}: {config_file}[/green]").format(
+ message=message, config_file=config_manager.config_file
+ )
+ )
+ else:
+ console.print(
+ _(
+ "[yellow]Authenticated swarm setting updated "
+ "(configuration not persisted - no config file)[/yellow]"
+ )
+ )
+
+
+@click.group("auth")
+def auth() -> None:
+ """Manage authenticated-swarms configuration."""
+
+
+@auth.command("status")
+@click.pass_context
+def auth_status(_ctx) -> None:
+ """Show authenticated swarms settings."""
+ try:
+ cfg = get_config()
+ security = getattr(cfg, "security", None)
+ if security is None:
+ console.print(_("[yellow]No security configuration loaded[/yellow]"))
+ return
+
+ auth_cfg = getattr(security, "authenticated_swarms", None)
+ if auth_cfg is None:
+ console.print(
+ _("[yellow]No authenticated swarms configuration found[/yellow]")
+ )
+ return
+
+ table = Table(title="Authenticated Swarms", show_header=True)
+ table.add_column("Setting", style="cyan")
+ table.add_column("Value", style="green")
+
+ trusted_ids = getattr(auth_cfg, "trusted_swarm_ids", [])
+ if isinstance(trusted_ids, list):
+ trusted_display = ", ".join(trusted_ids) if trusted_ids else "none"
+ else:
+ trusted_display = "invalid"
+
+ table.add_row("Mode", str(getattr(auth_cfg, "mode", "off")))
+ table.add_row(
+ "Discovery Mode", str(getattr(auth_cfg, "discovery_mode", "trackers_only"))
+ )
+ table.add_row(
+ "Discovery strict for strict mode",
+ str(bool(getattr(auth_cfg, "discovery_strict_for_strict_mode", False))),
+ )
+ table.add_row("Trusted swarm IDs", trusted_display)
+ table.add_row(
+ "Fail closed on parse errors",
+ str(bool(getattr(auth_cfg, "fail_closed_on_parse_errors", False))),
+ )
+ table.add_row(
+ "Trust store path",
+ str(getattr(auth_cfg, "trust_store_path", "")) or "not configured",
+ )
+ table.add_row(
+ "Trust store refresh interval (s)",
+ str(float(getattr(auth_cfg, "trust_store_refresh_interval_s", 60.0))),
+ )
+ table.add_row(
+ "Revocation profile path",
+ str(getattr(auth_cfg, "revocation_profile_path", "")) or "not configured",
+ )
+ table.add_row(
+ "Revocation refresh interval (s)",
+ str(float(getattr(auth_cfg, "revocation_refresh_interval_s", 300.0))),
+ )
+
+ console.print(table)
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(
+ _("[red]Error reading authenticated swarm status: {e}[/red]").format(e=e)
+ )
+ raise click.Abort from e
+
+
+@auth.command("set-mode")
+@click.argument(
+ "mode", type=click.Choice(["off", "opportunistic", "strict"], case_sensitive=False)
+)
+@click.pass_context
+def auth_set_mode(ctx, mode: str) -> None:
+ """Set authenticated-swarms admission mode."""
+ try:
+ config_manager, auth_cfg = _load_auth_config(ctx)
+ auth_cfg.mode = str(mode).strip().lower()
+ _persist_auth_config(
+ config_manager,
+ f"Authenticated swarm mode set to {auth_cfg.mode}",
+ )
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(
+ _("[red]Error updating authenticated swarm mode: {e}[/red]").format(e=e)
+ )
+ raise click.Abort from e
+
+
+@auth.command("set-discovery-mode")
+@click.argument(
+ "mode",
+ type=click.Choice(
+ [
+ "full",
+ "trackers_only",
+ "dht_only",
+ "pex_off",
+ "trackers-only",
+ "dht-only",
+ "pex-off",
+ ],
+ case_sensitive=False,
+ ),
+)
+@click.pass_context
+def auth_set_discovery_mode(ctx, mode: str) -> None:
+ """Set authenticated-swarms discovery mode."""
+ normalized = _normalize_discovery_mode(mode)
+ try:
+ config_manager, auth_cfg = _load_auth_config(ctx)
+ auth_cfg.discovery_mode = normalized
+ _persist_auth_config(
+ config_manager,
+ f"Authenticated swarm discovery mode set to {auth_cfg.discovery_mode}",
+ )
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(_("[red]Error updating discovery mode: {e}[/red]").format(e=e))
+ raise click.Abort from e
+
+
+@auth.command("set-discovery-strict")
+@click.argument(
+ "enabled",
+ type=click.Choice(["true", "false", "1", "0", "yes", "no"], case_sensitive=False),
+)
+@click.pass_context
+def auth_set_discovery_strict(ctx, enabled: str) -> None:
+ """Set whether strict mode enables strict discovery policy."""
+ try:
+ config_manager, auth_cfg = _load_auth_config(ctx)
+ auth_cfg.discovery_strict_for_strict_mode = _normalize_bool(enabled)
+ _persist_auth_config(
+ config_manager,
+ f"Authenticated swarm strict discovery set to {auth_cfg.discovery_strict_for_strict_mode}",
+ )
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(
+ _("[red]Error updating strict discovery mode: {e}[/red]").format(e=e)
+ )
+ raise click.Abort from e
+
+
+@auth.command("set-trusted-ids")
+@click.argument("ids", nargs=-1)
+@click.option("--clear", "-C", is_flag=True, default=False)
+@click.pass_context
+def auth_set_trusted_ids(ctx, ids: Iterable[str], clear: bool) -> None:
+ """Set explicit trusted swarm ids list."""
+ try:
+ config_manager, auth_cfg = _load_auth_config(ctx)
+ if clear:
+ auth_cfg.trusted_swarm_ids = []
+ else:
+ auth_cfg.trusted_swarm_ids = [str(v).strip() for v in ids if str(v).strip()]
+ _persist_auth_config(config_manager, "Updated trusted swarm IDs")
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(_("[red]Error updating trusted IDs: {e}[/red]").format(e=e))
+ raise click.Abort from e
+
+
+@auth.command("set-fail-closed-on-parse-errors")
+@click.argument(
+ "enabled",
+ type=click.Choice(["true", "false", "1", "0", "yes", "no"], case_sensitive=False),
+)
+@click.pass_context
+def auth_set_parse_failure_mode(ctx, enabled: str) -> None:
+ """Set fail-closed behavior for parse/reload errors."""
+ try:
+ config_manager, auth_cfg = _load_auth_config(ctx)
+ auth_cfg.fail_closed_on_parse_errors = _normalize_bool(enabled)
+ _persist_auth_config(
+ config_manager,
+ f"Updated fail-closed parse-policy to {auth_cfg.fail_closed_on_parse_errors}",
+ )
+ except Exception as e: # pragma: no cover - CLI error handler
+ console.print(
+ _("[red]Error updating parse-policy behavior: {e}[/red]").format(e=e)
+ )
+ raise click.Abort from e
diff --git a/ccbt/cli/cli_option_sets.py b/ccbt/cli/cli_option_sets.py
new file mode 100644
index 00000000..ef42109e
--- /dev/null
+++ b/ccbt/cli/cli_option_sets.py
@@ -0,0 +1,360 @@
+"""Reusable Click option decorator sets (short/long parity for heavy commands)."""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import TypeVar
+
+import click
+
+from ccbt.i18n import _
+
+F = TypeVar("F", bound=Callable[..., object])
+
+
+def compose_click_options(*decorators: Callable[[F], F]) -> Callable[[F], F]:
+ """Apply Click option decorators in source order (top option = outermost)."""
+
+ def _compose(f: F) -> F:
+ for dec in reversed(decorators):
+ f = dec(f)
+ return f
+
+ return _compose
+
+
+# Shared by ``download`` and ``magnet`` after each command's specific options.
+# Short letters avoid ``-o/-i/-m/-r`` (command-specific) and ``-F`` (magnet
+# ``--select-files``). Expert knobs listed in ``CLI_SHORT_FLAG_EXCEPTIONS`` stay
+# long-only so every other flag keeps a single-letter alias.
+DOWNLOAD_MAGNET_SHARED_OPTIONS: tuple[Callable[[F], F], ...] = (
+ click.option(
+ "--no-checkpoint",
+ "-N",
+ is_flag=True,
+ help=_("Disable checkpointing"),
+ ),
+ click.option(
+ "--checkpoint-dir",
+ "-C",
+ type=click.Path(),
+ help=_("Checkpoint directory"),
+ ),
+ click.option("--listen-port", "-L", type=int, help=_("Listen port")),
+ click.option("--max-peers", "-p", type=int, help=_("Maximum global peers")),
+ click.option(
+ "--max-peers-per-torrent",
+ "-P",
+ type=int,
+ help=_("Maximum peers per torrent"),
+ ),
+ click.option(
+ "--pipeline-depth",
+ "-f",
+ type=int,
+ help=_("Request pipeline depth"),
+ ),
+ click.option("--block-size-kib", "-B", type=int, help=_("Block size (KiB)")),
+ click.option(
+ "--connection-timeout",
+ "-T",
+ type=float,
+ help=_("Connection timeout (s)"),
+ ),
+ click.option(
+ "--download-limit",
+ "-D",
+ type=int,
+ help=_("Global download limit (KiB/s)"),
+ ),
+ click.option(
+ "--upload-limit",
+ "-U",
+ type=int,
+ help=_("Global upload limit (KiB/s)"),
+ ),
+ click.option("--dht-port", "-j", type=int, help=_("DHT port")),
+ click.option("--enable-dht", "-y", is_flag=True, help=_("Enable DHT")),
+ click.option("--disable-dht", "-Y", is_flag=True, help=_("Disable DHT")),
+ click.option(
+ "--piece-selection",
+ "-S",
+ type=click.Choice(["round_robin", "rarest_first", "sequential"]),
+ ),
+ click.option(
+ "--endgame-threshold",
+ "-e",
+ type=float,
+ help=_("Endgame threshold (0..1)"),
+ ),
+ click.option(
+ "--hash-workers",
+ "-w",
+ type=int,
+ help=_("Hash verification workers"),
+ ),
+ click.option("--disk-workers", "-x", type=int, help=_("Disk I/O workers")),
+ click.option("--use-mmap", "-a", is_flag=True, help=_("Use memory mapping")),
+ click.option(
+ "--no-mmap",
+ "-A",
+ is_flag=True,
+ help=_("Disable memory mapping"),
+ ),
+ click.option(
+ "--mmap-cache-mb",
+ "-b",
+ type=int,
+ help=_("MMap cache size (MB)"),
+ ),
+ click.option(
+ "--write-batch-kib",
+ "-g",
+ type=int,
+ help=_("Write batch size (KiB)"),
+ ),
+ click.option(
+ "--write-buffer-kib",
+ "-z",
+ type=int,
+ help=_("Write buffer size (KiB)"),
+ ),
+ click.option(
+ "--preallocate",
+ "-k",
+ type=click.Choice(["none", "sparse", "full"]),
+ ),
+ click.option(
+ "--sparse-files",
+ "-s",
+ is_flag=True,
+ help=_("Enable sparse files"),
+ ),
+ click.option(
+ "--no-sparse-files",
+ "-K",
+ is_flag=True,
+ help=_("Disable sparse files"),
+ ),
+ click.option(
+ "--enable-io-uring",
+ "-n",
+ is_flag=True,
+ help=_("Enable io_uring on Linux if available"),
+ ),
+ click.option(
+ "--disable-io-uring",
+ "-V",
+ is_flag=True,
+ help=_("Disable io_uring usage"),
+ ),
+ click.option(
+ "--direct-io",
+ "-d",
+ is_flag=True,
+ help=_("Enable direct I/O for writes when supported"),
+ ),
+ click.option(
+ "--sync-writes",
+ "-u",
+ is_flag=True,
+ help=_("Enable fsync after batched writes"),
+ ),
+ click.option(
+ "--log-level",
+ "-l",
+ type=click.Choice(["DEBUG", "TRACE", "INFO", "WARNING", "ERROR", "CRITICAL"]),
+ ),
+ click.option(
+ "--enable-metrics",
+ "-H",
+ is_flag=True,
+ help=_("Enable metrics"),
+ ),
+ click.option(
+ "--disable-metrics",
+ "-M",
+ is_flag=True,
+ help=_("Disable metrics"),
+ ),
+ click.option("--metrics-port", "-O", type=int, help=_("Metrics port")),
+ click.option("--enable-ipv6", "-E", is_flag=True, help=_("Enable IPv6")),
+ click.option("--disable-ipv6", "-X", is_flag=True, help=_("Disable IPv6")),
+ click.option("--enable-tcp", "-t", is_flag=True, help=_("Enable TCP transport")),
+ click.option(
+ "--disable-tcp",
+ "-G",
+ is_flag=True,
+ help=_("Disable TCP transport"),
+ ),
+ click.option("--enable-utp", "-q", is_flag=True, help=_("Enable uTP transport")),
+ click.option(
+ "--disable-utp",
+ "-Q",
+ is_flag=True,
+ help=_("Disable uTP transport"),
+ ),
+ click.option(
+ "--enable-encryption",
+ "-c",
+ is_flag=True,
+ help=_("Enable protocol encryption"),
+ ),
+ click.option(
+ "--disable-encryption",
+ "-Z",
+ is_flag=True,
+ help=_("Disable protocol encryption"),
+ ),
+ click.option(
+ "--tcp-nodelay",
+ "-J",
+ is_flag=True,
+ help=_("Enable TCP_NODELAY"),
+ ),
+ click.option(
+ "--no-tcp-nodelay",
+ "-I",
+ is_flag=True,
+ help=_("Disable TCP_NODELAY"),
+ ),
+ click.option(
+ "--socket-rcvbuf-kib",
+ type=int,
+ help=_("Socket receive buffer (KiB)"),
+ ),
+ click.option(
+ "--socket-sndbuf-kib",
+ type=int,
+ help=_("Socket send buffer (KiB)"),
+ ),
+ click.option(
+ "--listen-interface",
+ type=str,
+ help=_("Listen interface"),
+ ),
+ click.option("--peer-timeout", type=float, help=_("Peer timeout (s)")),
+ click.option("--dht-timeout", type=float, help=_("DHT timeout (s)")),
+ click.option(
+ "--min-block-size-kib",
+ type=int,
+ help=_("Minimum block size (KiB)"),
+ ),
+ click.option(
+ "--max-block-size-kib",
+ type=int,
+ help=_("Maximum block size (KiB)"),
+ ),
+ click.option(
+ "--enable-http-trackers",
+ is_flag=True,
+ help=_("Enable HTTP trackers"),
+ ),
+ click.option(
+ "--disable-http-trackers",
+ is_flag=True,
+ help=_("Disable HTTP trackers"),
+ ),
+ click.option(
+ "--enable-udp-trackers",
+ is_flag=True,
+ help=_("Enable UDP trackers"),
+ ),
+ click.option(
+ "--disable-udp-trackers",
+ is_flag=True,
+ help=_("Disable UDP trackers"),
+ ),
+ click.option(
+ "--tracker-announce-interval",
+ type=float,
+ help=_("Tracker announce interval (s)"),
+ ),
+ click.option(
+ "--tracker-scrape-interval",
+ type=float,
+ help=_("Tracker scrape interval (s)"),
+ ),
+ click.option("--pex-interval", type=float, help=_("PEX interval (s)")),
+ click.option(
+ "--endgame-duplicates",
+ type=int,
+ help=_("Endgame duplicate requests"),
+ ),
+ click.option(
+ "--streaming-mode",
+ is_flag=True,
+ help=_("Enable streaming mode"),
+ ),
+ click.option(
+ "--first-piece-priority",
+ is_flag=True,
+ help=_("Prioritize first piece"),
+ ),
+ click.option(
+ "--last-piece-priority",
+ is_flag=True,
+ help=_("Prioritize last piece"),
+ ),
+ click.option(
+ "--optimistic-unchoke-interval",
+ type=float,
+ help=_("Optimistic unchoke interval (s)"),
+ ),
+ click.option(
+ "--unchoke-interval",
+ type=float,
+ help=_("Unchoke interval (s)"),
+ ),
+ click.option(
+ "--peer-choked-hard-timeout-seconds",
+ type=float,
+ help=_("Hard recovery base timeout if remote still chokes (s)"),
+ ),
+ click.option(
+ "--peer-choked-anchor-timeout-seconds",
+ type=float,
+ help=_("UNCHOKE wait for seed-anchor peers (s)"),
+ ),
+ click.option(
+ "--peer-choked-solo-grace-seconds",
+ type=float,
+ help=_("Min grace when solo or no requestable peers (s)"),
+ ),
+ click.option(
+ "--peer-choked-solo-grace-zero-bytes-cap-seconds",
+ type=float,
+ help=_("Cap solo grace when zero bytes/outstanding (0=off)"),
+ ),
+ click.option(
+ "--metrics-interval",
+ type=float,
+ help=_("Metrics interval (s)"),
+ ),
+ click.option(
+ "--enable-v2",
+ "-R",
+ "enable_v2",
+ is_flag=True,
+ help=_("Enable Protocol v2 (BEP 52)"),
+ ),
+ click.option(
+ "--disable-v2",
+ "-W",
+ "disable_v2",
+ is_flag=True,
+ help=_("Disable Protocol v2 (BEP 52)"),
+ ),
+ click.option(
+ "--prefer-v2",
+ "prefer_v2",
+ is_flag=True,
+ help=_("Prefer Protocol v2 when available"),
+ ),
+ click.option(
+ "--v2-only",
+ "v2_only",
+ is_flag=True,
+ help=_("Use Protocol v2 only (disable v1)"),
+ ),
+)
diff --git a/ccbt/cli/cli_short_flag_exceptions.py b/ccbt/cli/cli_short_flag_exceptions.py
new file mode 100644
index 00000000..7c2d9165
--- /dev/null
+++ b/ccbt/cli/cli_short_flag_exceptions.py
@@ -0,0 +1,48 @@
+"""Long-only CLI options exempt from the short-alias audit.
+
+Expert tuning flags on ``download`` / ``magnet`` share a single pool of ASCII
+shorts with command-specific options; the densest tracker/socket knobs stay
+long-only. Secret and ambiguous flags stay long-only by policy.
+"""
+
+from __future__ import annotations
+
+# Normalized long option names (with leading ``--``) exempt from requiring a
+# short form in ``ccbt/cli`` (see tests/unit/cli/test_cli_short_flag_audit.py).
+CLI_SHORT_FLAG_EXCEPTIONS: frozenset[str] = frozenset(
+ {
+ # download / magnet: expert tuning (registry: docs/en/configuration.md)
+ "--socket-rcvbuf-kib",
+ "--socket-sndbuf-kib",
+ "--listen-interface",
+ "--peer-timeout",
+ "--dht-timeout",
+ "--min-block-size-kib",
+ "--max-block-size-kib",
+ "--enable-http-trackers",
+ "--disable-http-trackers",
+ "--enable-udp-trackers",
+ "--disable-udp-trackers",
+ "--tracker-announce-interval",
+ "--tracker-scrape-interval",
+ "--pex-interval",
+ "--endgame-duplicates",
+ "--streaming-mode",
+ "--first-piece-priority",
+ "--last-piece-priority",
+ "--optimistic-unchoke-interval",
+ "--unchoke-interval",
+ "--peer-choked-hard-timeout-seconds",
+ "--peer-choked-anchor-timeout-seconds",
+ "--peer-choked-solo-grace-seconds",
+ "--peer-choked-solo-grace-zero-bytes-cap-seconds",
+ "--metrics-interval",
+ "--prefer-v2",
+ "--v2-only",
+ # Secrets: avoid guessable short for passwords
+ "--pass",
+ # Verbose count aliases (parent already uses ``-v`` / ``-vv`` style)
+ "--vv",
+ "--vvv",
+ },
+)
diff --git a/ccbt/cli/config_commands.py b/ccbt/cli/config_commands.py
index 59557db2..68b97755 100644
--- a/ccbt/cli/config_commands.py
+++ b/ccbt/cli/config_commands.py
@@ -1,16 +1,18 @@
"""Configuration management CLI commands for ccBitTorrent.
-Adds commands:
-- config show
-- config get
-- config set
-- config reset
-- config validate
-- config migrate
+Core commands:
+
+- ``config show`` / ``get`` / ``set`` / ``apply`` / ``describe`` / ``reset``
+- ``config validate`` / ``migrate``
+
+Extended commands (same ``btbt config`` group; see ``config_commands_extended``) are
+registered when this module finishes loading: ``schema``, ``import``, ``export``,
+``template``, ``profile``, ``backup``, ``restore``, ``diff``, ``auto-tune``, etc.
"""
from __future__ import annotations
+import copy
import json
import logging
import os
@@ -20,9 +22,13 @@
import click
import toml
+from ccbt.cli.config_group import config
from ccbt.cli.config_utils import requires_daemon_restart, restart_daemon_if_needed
from ccbt.config.config import ConfigManager
+from ccbt.config.config_cli_values import parse_cli_config_value, set_nested_dict
+from ccbt.config.config_schema import ConfigDiscovery
from ccbt.i18n import _
+from ccbt.utils.exceptions import ConfigurationError
logger = logging.getLogger(__name__)
@@ -104,38 +110,52 @@ def _should_skip_project_local_write(
return False
-@click.group()
-def config():
- """Manage configuration commands."""
-
-
@config.command("show")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["toml", "json", "yaml"]),
default="toml",
)
@click.option(
"--section",
+ "-S",
type=str,
default=None,
help=_("Show specific section key path (e.g. network)"),
)
@click.option(
"--key",
+ "-k",
type=str,
default=None,
help=_("Show specific key path (e.g. network.listen_port)"),
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
def show_config(
format_: str,
section: Optional[str],
key: Optional[str],
config_file: Optional[str],
):
- """Show current configuration in the desired format."""
+ """Show effective configuration (merged file, environment, and defaults).
+
+ This prints resolved values only. For every option path with types and defaults,
+ use ``btbt config describe`` (add ``--include-current`` to compare). For JSON
+ Schema, use ``btbt config schema``. To merge a patch file into ``ccbt.toml``,
+ use ``btbt config apply``.
+
+ MSE / peer encryption (effective values): ``btbt config security-posture`` or
+ ``btbt config show -S security -f json`` (see ``security.enable_encryption``,
+ ``encryption_mode``) and ``network.enable_encryption`` (mirror).
+ """
cm = ConfigManager(config_file)
data = cm.config.model_dump(mode="json")
# filter by section/key
@@ -172,11 +192,78 @@ def show_config(
click.echo(toml.dumps(data))
+@config.command("security-posture")
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
+def security_posture(config_file: Optional[str]):
+ """Print effective MSE/PE and related security fields (file + env merged).
+
+ Same merge order as ``config show``. Use this to verify why the session logs
+ ``mse_enabled=`` (maps to ``security.enable_encryption``).
+ """
+ cm = ConfigManager(config_file)
+ sec = cm.config.security
+ net = cm.config.network
+ out = {
+ "security.enable_encryption": sec.enable_encryption,
+ "security.encryption_mode": sec.encryption_mode,
+ "security.encryption_allow_plain_fallback": sec.encryption_allow_plain_fallback,
+ "security.encryption_dh_key_size": sec.encryption_dh_key_size,
+ "network.enable_encryption": net.enable_encryption,
+ "network.peer_quality_probation_timeout": net.peer_quality_probation_timeout,
+ }
+ click.echo(json.dumps(out, indent=2))
+
+
+@config.command("peer-cap-provenance")
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
+def peer_cap_provenance(config_file: Optional[str]) -> None:
+ """Print max_peers_per_torrent resolution chain (file → profile → env → clamp).
+
+ Same merge path as ``config show``. Does not include per-torrent overrides
+ (those apply when a torrent session binds its peer manager).
+ """
+ cm = ConfigManager(config_file)
+ prov = cm.max_peers_per_torrent_provenance
+ if prov is None:
+ click.echo(
+ json.dumps(
+ {
+ "error": "peer_cap_provenance_unavailable",
+ "hint": "Load failed before provenance was recorded.",
+ },
+ indent=2,
+ )
+ )
+ return
+ click.echo(json.dumps(prov.model_dump(mode="json"), indent=2))
+
+
@config.command("get")
@click.argument("key")
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
def get_value(key: str, config_file: Optional[str]):
- """Get a specific configuration value by dotted path."""
+ """Get one effective value by dotted path (same merge as ``config show``).
+
+ See ``btbt config describe`` for all valid paths and defaults.
+ """
cm = ConfigManager(config_file)
data = cm.config.model_dump(mode="json")
ref = data
@@ -189,24 +276,160 @@ def get_value(key: str, config_file: Optional[str]):
raise click.ClickException(msg) from None
+@config.command("describe")
+@click.option(
+ "--format",
+ "-f",
+ "format_",
+ type=click.Choice(["table", "json", "yaml"]),
+ default="table",
+ help=_("Output format for the option catalog"),
+)
+@click.option(
+ "--section",
+ "-S",
+ type=str,
+ default=None,
+ help=_("Only options in this top-level section (e.g. network)"),
+)
+@click.option(
+ "--path-prefix",
+ "-p",
+ type=str,
+ default=None,
+ help=_("Only paths starting with this prefix"),
+)
+@click.option(
+ "--include-current",
+ "-i",
+ is_flag=True,
+ help=_("Include effective runtime value from loaded config (file + env)"),
+)
+@click.option("-o", "--output", type=click.Path(), default=None)
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
+def describe_config(
+ format_: str,
+ section: Optional[str],
+ path_prefix: Optional[str],
+ include_current: bool,
+ output: Optional[str],
+ config_file: Optional[str],
+):
+ """List all configuration options (nested paths), types, defaults, descriptions.
+
+ Complements ``config show`` / ``config get`` (values only) and
+ ``btbt config schema`` (full JSON Schema). To change values use ``config set``
+ or ``config apply`` / ``config import --mode merge``.
+ """
+ from ccbt.config.config_cli_values import get_nested_value
+
+ rows = ConfigDiscovery.list_all_options_nested()
+ if section:
+ rows = [
+ r
+ for r in rows
+ if r["section"] == section or r["path"].startswith(f"{section}.")
+ ]
+ if path_prefix:
+ rows = [r for r in rows if r["path"].startswith(path_prefix)]
+
+ current_data: dict = {}
+ if include_current:
+ cm = ConfigManager(config_file)
+ current_data = cm.config.model_dump(mode="json")
+ for r in rows:
+ r["current"] = get_nested_value(current_data, r["path"])
+
+ if format_ == "json":
+ out = json.dumps(rows, indent=2, default=str)
+ elif format_ == "yaml":
+ try:
+ import yaml
+ except ImportError as e:
+ raise click.ClickException(_("PyYAML is required for YAML output")) from e
+ out = yaml.safe_dump(rows, sort_keys=False)
+ else:
+ from rich.console import Console
+ from rich.table import Table
+
+ table = Table(title=_("Configuration options"))
+ table.add_column(_("Path"), style="cyan", no_wrap=True)
+ table.add_column(_("Type"), style="green")
+ table.add_column(_("Required"), style="yellow")
+ table.add_column(_("Default"), max_width=36)
+ if include_current:
+ table.add_column(_("Current"), max_width=36)
+ table.add_column(_("Description"), max_width=48)
+ for r in rows:
+ row_cells = [
+ r["path"],
+ str(r["type"]),
+ _("yes") if r["required"] else _("no"),
+ json.dumps(r["default"], default=str)
+ if r["default"] is not None
+ else "",
+ ]
+ if include_current:
+ cur = r.get("current", None)
+ row_cells.append(
+ json.dumps(cur, default=str) if cur is not None else ""
+ )
+ row_cells.append((r.get("description") or "")[:2000])
+ table.add_row(*row_cells)
+ console = Console(record=True)
+ console.print(table)
+ out = console.export_text()
+
+ if output:
+ Path(output).write_text(out, encoding="utf-8")
+ click.echo(_("Wrote catalog to {path}").format(path=output))
+ else:
+ click.echo(out)
+
+
@config.command("set")
@click.argument("key")
-@click.argument("value")
+@click.argument("value", required=False, default=None)
+@click.option(
+ "--value",
+ "-V",
+ "value_opt",
+ default=None,
+ help=_(
+ "Value to set (use for strings with spaces or JSON); overrides positional VALUE"
+ ),
+)
+@click.option(
+ "--dry-run",
+ "-n",
+ "dry_run",
+ is_flag=True,
+ help=_("Validate only; do not write the config file"),
+)
@click.option(
"--global",
+ "-G",
"global_flag",
is_flag=True,
help=_("Set value in global config file"),
)
@click.option(
"--local",
+ "-L",
"local_flag",
is_flag=True,
help=_("Set value in project local ccbt.toml"),
)
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -214,6 +437,7 @@ def get_value(key: str, config_file: Optional[str]):
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
@@ -221,7 +445,9 @@ def get_value(key: str, config_file: Optional[str]):
)
def set_value(
key: str,
- value: str,
+ value: Optional[str],
+ value_opt: Optional[str],
+ dry_run: bool,
global_flag: bool,
local_flag: bool,
config_file: Optional[str],
@@ -230,8 +456,23 @@ def set_value(
):
"""Set a configuration value and persist to TOML file.
+ Values are parsed as JSON when valid (numbers, booleans, arrays, objects).
+ Otherwise booleans, numbers, comma-separated lists (for known list paths), or strings.
+
Precedence for destination: --config > --local (./ccbt.toml) > --global (~/.config/ccbt/ccbt.toml)
+
+ After writing, effective runtime config still follows normal precedence: environment
+ variables can override the same keys from the file. Use ``btbt config describe`` to
+ list paths; use ``btbt config apply`` for multi-key patches.
"""
+ raw = value_opt if value_opt is not None else value
+ if raw is None:
+ raise click.UsageError(
+ _(
+ "Provide a VALUE argument or use --value=... for values with spaces or JSON"
+ )
+ )
+
# choose target file
if config_file:
target = Path(config_file)
@@ -251,35 +492,31 @@ def set_value(
except Exception:
current = {}
- def parse_value(raw: str):
- low = raw.lower()
- if low in {"true", "1", "yes", "on"}:
- return True
- if low in {"false", "0", "no", "off"}:
- return False
- try:
- if "." in raw:
- return float(raw)
- return int(raw)
- except ValueError:
- return raw
-
- parts = key.split(".")
- ref = current
- for p in parts[:-1]:
- ref = ref.setdefault(p, {})
- ref[parts[-1]] = parse_value(value)
+ proposed = copy.deepcopy(current)
+ parsed = parse_cli_config_value(raw, key)
+ set_nested_dict(proposed, key, parsed)
+
+ validate_path = str(target) if target.exists() else config_file
+ validate_cm = ConfigManager(validate_path)
+ try:
+ validate_cm.simulate_load_from_file_dict(proposed)
+ except ConfigurationError as e:
+ raise click.ClickException(str(e)) from e
# Safety: avoid overwriting project-local config during tests
if _should_skip_project_local_write(target, config_file):
click.echo(_("OK")) # pragma: no cover - Test mode protection path
return # pragma: no cover - Test mode protection path
+ if dry_run:
+ click.echo(_("OK (dry-run — configuration is valid)"))
+ return
+
# Load old config before modification
old_config_manager = ConfigManager(config_file)
old_config = old_config_manager.config
- target.write_text(toml.dumps(current), encoding="utf-8")
+ target.write_text(toml.dumps(proposed), encoding="utf-8")
click.echo(str(target))
# Check if restart is needed
@@ -306,13 +543,180 @@ def parse_value(raw: str):
# Don't fail the command if restart check fails
+@config.command("apply")
+@click.argument("input_file", required=False, type=click.Path(exists=True))
+@click.option(
+ "--format",
+ "-f",
+ "format_",
+ type=click.Choice(["toml", "json", "yaml", "auto"]),
+ default="auto",
+ help=_("Patch file format (auto: infer from extension or try JSON then TOML)"),
+)
+@click.option(
+ "--global",
+ "-G",
+ "global_flag",
+ is_flag=True,
+ help=_("Write merged config to global config file"),
+)
+@click.option(
+ "--local",
+ "-L",
+ "local_flag",
+ is_flag=True,
+ help=_("Write merged config to project local ccbt.toml"),
+)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
+@click.option(
+ "--dry-run",
+ "-n",
+ "dry_run",
+ is_flag=True,
+ help=_("Validate merged file overlay only; do not write"),
+)
+@click.option(
+ "--restart-daemon",
+ "-R",
+ "restart_daemon_flag",
+ is_flag=True,
+ default=None,
+ help=_("Automatically restart daemon if needed (without prompt)"),
+)
+@click.option(
+ "--no-restart-daemon",
+ "-N",
+ "no_restart_daemon_flag",
+ is_flag=True,
+ default=None,
+ help=_("Skip daemon restart even if needed"),
+)
+def apply_config_patch(
+ input_file: Optional[str],
+ format_: str,
+ global_flag: bool,
+ local_flag: bool,
+ config_file: Optional[str],
+ dry_run: bool,
+ restart_daemon_flag: Optional[bool],
+ no_restart_daemon_flag: Optional[bool],
+):
+ """Merge a partial config object into the target TOML and validate before write.
+
+ For a single key, prefer ``btbt config set``. For a full document replace, use
+ ``btbt config import --mode replace``; for merge semantics similar to this command
+ from a file, ``btbt config import --mode merge``. See ``btbt config describe`` for paths.
+ """
+ import sys
+
+ from ccbt.config.config_templates import ConfigTemplates
+
+ if input_file:
+ raw = Path(input_file).read_text(encoding="utf-8")
+ src = Path(input_file)
+ else:
+ raw = sys.stdin.read()
+ src = None
+
+ fmt = format_
+ if fmt == "auto" and src is not None:
+ suf = src.suffix.lower()
+ if suf in {".yml", ".yaml"}:
+ fmt = "yaml"
+ elif suf == ".json":
+ fmt = "json"
+ elif suf == ".toml":
+ fmt = "toml"
+
+ if fmt == "auto":
+ try:
+ patch = json.loads(raw)
+ except json.JSONDecodeError:
+ patch = toml.loads(raw)
+ elif fmt == "json":
+ patch = json.loads(raw)
+ elif fmt == "yaml":
+ try:
+ import yaml
+ except ImportError as e:
+ raise click.ClickException(_("PyYAML is required for YAML patches")) from e
+ loaded = yaml.safe_load(raw)
+ patch = loaded if isinstance(loaded, dict) else {}
+ else:
+ patch = toml.loads(raw)
+
+ if not isinstance(patch, dict):
+ raise click.ClickException(
+ _("Patch must be a JSON/TOML object at the top level")
+ )
+
+ if config_file:
+ target = Path(config_file)
+ elif local_flag:
+ target = Path.cwd() / "ccbt.toml"
+ elif global_flag:
+ target = Path.home() / ".config" / "ccbt" / "ccbt.toml"
+ else:
+ target = Path.cwd() / "ccbt.toml"
+
+ target.parent.mkdir(parents=True, exist_ok=True)
+ base: dict = {}
+ if target.exists():
+ try:
+ base = toml.load(str(target))
+ except Exception:
+ base = {}
+
+ merged = ConfigTemplates._deep_merge(base, patch) # noqa: SLF001
+
+ validate_path = str(target) if target.exists() else config_file
+ validate_cm = ConfigManager(validate_path)
+ try:
+ validate_cm.simulate_load_from_file_dict(merged)
+ except ConfigurationError as e:
+ raise click.ClickException(str(e)) from e
+
+ if _should_skip_project_local_write(target, config_file):
+ click.echo(_("OK"))
+ return
+
+ if dry_run:
+ click.echo(_("OK (dry-run — merged configuration is valid)"))
+ return
+
+ old_config_manager = ConfigManager(config_file)
+ old_config = old_config_manager.config
+
+ target.write_text(toml.dumps(merged), encoding="utf-8")
+ click.echo(str(target))
+
+ try:
+ new_config_manager = ConfigManager(config_file)
+ new_config = new_config_manager.config
+ needs_restart = requires_daemon_restart(old_config, new_config)
+ if needs_restart:
+ auto_restart = None
+ if restart_daemon_flag:
+ auto_restart = True
+ elif no_restart_daemon_flag:
+ auto_restart = False
+ restart_daemon_if_needed(
+ new_config_manager,
+ requires_restart=True,
+ auto_restart=auto_restart,
+ )
+ except Exception as e:
+ logger.debug(_("Error checking if restart is needed: %s"), e)
+
+
@config.command("reset")
-@click.option("--section", type=str, default=None)
-@click.option("--key", type=str, default=None)
-@click.option("--confirm", is_flag=True, help=_("Skip confirmation prompt"))
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--section", "-S", type=str, default=None)
+@click.option("--key", "-k", type=str, default=None)
+@click.option("--confirm", "-y", is_flag=True, help=_("Skip confirmation prompt"))
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -320,6 +724,7 @@ def parse_value(raw: str):
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
@@ -399,21 +804,54 @@ def reset_config(
@config.command("validate")
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
-def validate_config_cmd(config_file: Optional[str]):
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
+@click.option(
+ "--detailed",
+ "-d",
+ is_flag=True,
+ help=_("Run additional system compatibility checks after model validation"),
+)
+def validate_config_cmd(config_file: Optional[str], detailed: bool):
"""Validate configuration file and print result."""
try:
- ConfigManager(config_file)
- click.echo(_("VALID"))
+ from ccbt.config.config_conditional import ConditionalConfig
+
+ cm = ConfigManager(config_file)
+ if detailed:
+ click.echo(_("✓ Configuration is valid"))
+ conditional_config = ConditionalConfig()
+ warnings = conditional_config.validate_against_system(cm.config)[1]
+ if warnings:
+ click.echo(_("Warnings:"))
+ for warning in warnings:
+ click.echo(_(" ⚠ {warning}").format(warning=warning))
+ else:
+ click.echo(_("✓ No system compatibility warnings"))
+ else:
+ click.echo(_("VALID"))
except Exception as e: # pragma: no cover - CLI error handler, hard to trigger reliably in unit tests
+ if detailed:
+ click.echo(_("✗ Configuration validation failed: {e}").format(e=e))
raise click.ClickException(str(e)) from e
@config.command("migrate")
-@click.option("--from-version", type=str, default=None)
-@click.option("--to-version", type=str, default=None)
-@click.option("--backup", is_flag=True, help=_("Create backup before migration"))
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option("--from-version", "-F", type=str, default=None)
+@click.option("--to-version", "-T", type=str, default=None)
+@click.option("--backup", "-b", is_flag=True, help=_("Create backup before migration"))
+@click.option(
+ "--config",
+ "-c",
+ "config_file",
+ type=click.Path(exists=True),
+ default=None,
+)
def migrate_config_cmd(
from_version: Optional[str], # noqa: ARG001
to_version: Optional[str], # noqa: ARG001
@@ -427,3 +865,6 @@ def migrate_config_cmd(
bak = Path(str(cm.config_file) + ".bak")
bak.write_text(cm.config_file.read_text(encoding="utf-8"), encoding="utf-8")
click.echo(_("MIGRATED"))
+
+
+import ccbt.cli.config_commands_extended # noqa: E402,F401 — attach extended subcommands
diff --git a/ccbt/cli/config_commands_extended.py b/ccbt/cli/config_commands_extended.py
index 35f74159..1e2fe1f1 100644
--- a/ccbt/cli/config_commands_extended.py
+++ b/ccbt/cli/config_commands_extended.py
@@ -47,7 +47,6 @@
import json
import logging
-import os
from pathlib import Path
from typing import Optional
@@ -56,7 +55,8 @@
from rich.console import Console
from rich.table import Table
-from ccbt.cli.config_commands import _find_project_root
+from ccbt.cli.config_commands import _should_skip_project_local_write
+from ccbt.cli.config_group import config
from ccbt.cli.config_utils import requires_daemon_restart, restart_daemon_if_needed
from ccbt.config.config import ConfigManager
from ccbt.config.config_backup import ConfigBackup
@@ -71,54 +71,10 @@
console = Console()
-def _should_skip_project_local_write(target_path: Path) -> bool:
- """Check if we should skip writing to project-local ccbt.toml during tests.
-
- Args:
- target_path: The target file path to write to
-
- Returns:
- True if we should skip writing (in test mode and targeting project-local file)
-
- """
- try: # pragma: no cover - Defensive exception handling for safeguard detection errors
- # Try to find project root from current directory or from target_path's directory
- project_root = _find_project_root()
- if target_path:
- # Also try from target_path's directory in case we're in a subdirectory
- alt_root = _find_project_root(
- target_path.parent
- if target_path.is_absolute()
- else Path.cwd() / target_path.parent
- )
- if alt_root is not None:
- project_root = alt_root
-
- if project_root is None:
- # Can't determine project root, allow write (fallback to old behavior)
- return False
-
- project_local = project_root / "ccbt.toml"
- is_test_env = bool(
- os.environ.get("PYTEST_CURRENT_TEST") or os.environ.get("CCBT_TEST_MODE")
- )
- # If target is the project-local file under test, skip destructive write
- if target_path.resolve() == project_local.resolve() and is_test_env:
- return True # pragma: no cover - Test mode protection path
- except Exception: # pragma: no cover - Defensive exception handling for safeguard detection errors (path resolution, environment access, etc.)
- # If any error in safeguard detection, proceed normally
- pass # pragma: no cover - Error handling path for safeguard detection failures
- return False
-
-
-@click.group(name="config-extended")
-def config_extended():
- """Provide extended configuration management commands."""
-
-
-@config_extended.command("schema")
+@config.command("schema")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["json", "yaml"]),
default="json",
@@ -126,6 +82,7 @@ def config_extended():
)
@click.option(
"--model",
+ "-m",
type=str,
default=None,
help="Specific model to generate schema for (e.g., Config, NetworkConfig)",
@@ -179,10 +136,11 @@ def schema_cmd(format_: str, model: Optional[str], output: Optional[str]):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("template")
+@config.command("template")
@click.argument("template_name")
@click.option(
"--apply",
+ "-a",
is_flag=True,
help="Apply template to current configuration",
)
@@ -192,9 +150,12 @@ def schema_cmd(format_: str, model: Optional[str], output: Optional[str]):
type=click.Path(),
help="Output file path for template config",
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config", "-c", "config_file", type=click.Path(exists=True), default=None
+)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -202,6 +163,7 @@ def schema_cmd(format_: str, model: Optional[str], output: Optional[str]):
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
@@ -259,7 +221,7 @@ def template_cmd(
target_path = Path(output) if output else Path.cwd() / "ccbt.toml"
# Safety: avoid overwriting project-local config during tests
- if _should_skip_project_local_write(target_path):
+ if _should_skip_project_local_write(target_path, None):
click.echo(_("OK")) # pragma: no cover - Test mode protection path
return # pragma: no cover - Test mode protection path
@@ -303,10 +265,11 @@ def template_cmd(
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("profile")
+@config.command("profile")
@click.argument("profile_name")
@click.option(
"--apply",
+ "-a",
is_flag=True,
help="Apply profile to current configuration",
)
@@ -316,9 +279,12 @@ def template_cmd(
type=click.Path(),
help="Output file path for profile config",
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config", "-c", "config_file", type=click.Path(exists=True), default=None
+)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -326,6 +292,7 @@ def template_cmd(
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
@@ -388,7 +355,7 @@ def profile_cmd(
target_path = Path(output) if output else Path.cwd() / "ccbt.toml"
# Safety: avoid overwriting project-local config during tests
- if _should_skip_project_local_write(target_path):
+ if _should_skip_project_local_write(target_path, None):
click.echo(_("OK")) # pragma: no cover - Test mode protection path
return # pragma: no cover - Test mode protection path
@@ -434,7 +401,7 @@ def profile_cmd(
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("backup")
+@config.command("backup")
@click.option(
"--description",
"-d",
@@ -444,11 +411,14 @@ def profile_cmd(
)
@click.option(
"--compress",
+ "-z",
is_flag=True,
default=True,
help="Compress backup",
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config", "-c", "config_file", type=click.Path(exists=True), default=None
+)
def backup_cmd(description: str, compress: bool, config_file: Optional[str]):
"""Create configuration backup."""
try:
@@ -481,14 +451,15 @@ def backup_cmd(description: str, compress: bool, config_file: Optional[str]):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("restore")
+@config.command("restore")
@click.argument("backup_file", type=click.Path(exists=True))
@click.option(
"--confirm",
+ "-y",
is_flag=True,
help="Skip confirmation prompt",
)
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
def restore_cmd(backup_file: str, confirm: bool, config_file: Optional[str]):
"""Restore configuration from backup."""
try:
@@ -518,9 +489,10 @@ def restore_cmd(backup_file: str, confirm: bool, config_file: Optional[str]):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("list-backups")
+@config.command("list-backups")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["table", "json"]),
default="table",
@@ -563,11 +535,12 @@ def list_backups_cmd(format_: str):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("diff")
+@config.command("diff")
@click.argument("config1", type=click.Path(exists=True))
@click.argument("config2", type=click.Path(exists=True))
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["unified", "json"]),
default="unified",
@@ -645,7 +618,7 @@ def _print_capabilities_summary() -> None:
console.print(table) # pragma: no cover - Rich table rendering difficult to test
-@config_extended.group("capabilities", invoke_without_command=True)
+@config.group("capabilities", invoke_without_command=True)
@click.pass_context
def capabilities_group(ctx):
"""Manage system capabilities."""
@@ -668,9 +641,10 @@ def capabilities_summary_cmd():
_print_capabilities_summary()
-@config_extended.command("auto-tune")
+@config.command("auto-tune")
@click.option(
"--apply",
+ "-a",
is_flag=True,
help="Apply auto-tuning to current configuration",
)
@@ -680,9 +654,12 @@ def capabilities_summary_cmd():
type=click.Path(),
help="Output file path for tuned config",
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config", "-c", "config_file", type=click.Path(exists=True), default=None
+)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -690,6 +667,7 @@ def capabilities_summary_cmd():
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
@@ -725,7 +703,8 @@ def auto_tune_cmd(
# Safety: avoid overwriting project-local config during tests (only check if writing to ccbt.toml)
if target_path.name == "ccbt.toml" and _should_skip_project_local_write(
- target_path
+ target_path,
+ None,
):
click.echo(_("OK")) # pragma: no cover - Test mode protection path
return # pragma: no cover - Test mode protection path
@@ -777,9 +756,10 @@ def auto_tune_cmd(
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("export")
+@config.command("export")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["toml", "json", "yaml"]),
default="toml",
@@ -792,7 +772,9 @@ def auto_tune_cmd(
required=True,
help="Output file path",
)
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
+@click.option(
+ "--config", "-c", "config_file", type=click.Path(exists=True), default=None
+)
def export_cmd(format_: str, output: str, config_file: Optional[str]):
"""Export configuration to file."""
try:
@@ -826,10 +808,11 @@ def export_cmd(format_: str, output: str, config_file: Optional[str]):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("import")
+@config.command("import")
@click.argument("import_file", type=click.Path(exists=True))
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["toml", "json", "yaml"]),
default=None,
@@ -841,9 +824,10 @@ def export_cmd(format_: str, output: str, config_file: Optional[str]):
type=click.Path(),
help="Output file path (default: overwrite current config)",
)
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.option(
"--restart-daemon",
+ "-R",
"restart_daemon_flag",
is_flag=True,
default=None,
@@ -851,11 +835,22 @@ def export_cmd(format_: str, output: str, config_file: Optional[str]):
)
@click.option(
"--no-restart-daemon",
+ "-N",
"no_restart_daemon_flag",
is_flag=True,
default=None,
help="Skip daemon restart even if needed",
)
+@click.option(
+ "--mode",
+ "-M",
+ type=click.Choice(["replace", "merge"]),
+ default="replace",
+ help=_(
+ "replace: file must be a full valid document; "
+ "merge: deep-merge into existing target TOML then validate"
+ ),
+)
def import_cmd(
import_file: str,
format_: Optional[str],
@@ -863,6 +858,7 @@ def import_cmd(
config_file: Optional[str],
restart_daemon_flag: Optional[bool],
no_restart_daemon_flag: Optional[bool],
+ mode: str,
):
"""Import configuration from file."""
try:
@@ -900,15 +896,9 @@ def import_cmd(
else:
config_data = toml.loads(file_content)
- # Validate configuration
- try:
- # Validate by creating a Config object
- from ccbt.models import Config
-
- Config.model_validate(config_data)
- except Exception as e: # pragma: no cover - Invalid config validation error
- click.echo(_("Invalid configuration: {e}").format(e=e)) # pragma: no cover
- return # pragma: no cover
+ if not isinstance(config_data, dict):
+ click.echo(_("Invalid configuration: top-level must be an object"))
+ return
# Save to target
if output:
@@ -919,12 +909,41 @@ def import_cmd(
target_path = Path.cwd() / "ccbt.toml"
# Safety: avoid overwriting project-local config during tests
- if _should_skip_project_local_write(target_path):
+ if _should_skip_project_local_write(target_path, None):
click.echo(_("OK")) # pragma: no cover - Test mode protection path
return # pragma: no cover - Test mode protection path
+ to_write: dict
+ if mode == "replace":
+ try:
+ from ccbt.models import Config
+
+ Config.model_validate(config_data)
+ except Exception as e: # pragma: no cover
+ click.echo(
+ _("Invalid configuration: {e}").format(e=e)
+ ) # pragma: no cover
+ return # pragma: no cover
+ to_write = config_data
+ else:
+ base: dict = {}
+ if target_path.exists():
+ try:
+ base = toml.load(str(target_path))
+ except Exception:
+ base = {}
+ to_write = ConfigTemplates._deep_merge(base, config_data) # noqa: SLF001
+ validate_cm = ConfigManager(
+ str(target_path) if target_path.exists() else config_file
+ )
+ try:
+ validate_cm.simulate_load_from_file_dict(to_write)
+ except Exception as e:
+ click.echo(_("Invalid configuration after merge: {e}").format(e=e))
+ return
+
target_path.parent.mkdir(parents=True, exist_ok=True)
- target_path.write_text(toml.dumps(config_data), encoding="utf-8")
+ target_path.write_text(toml.dumps(to_write), encoding="utf-8")
click.echo(_("Configuration imported to {path}").format(path=target_path))
# Check if restart is needed
@@ -961,44 +980,10 @@ def import_cmd(
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("validate")
-@click.option("--config", "config_file", type=click.Path(exists=True), default=None)
-@click.option(
- "--detailed",
- is_flag=True,
- help="Show detailed validation results",
-)
-def validate_cmd(config_file: Optional[str], detailed: bool):
- """Validate configuration file."""
- try:
- cm = ConfigManager(config_file)
- config = cm.config
-
- # Basic validation (this happens during ConfigManager creation)
- click.echo(_("✓ Configuration is valid"))
-
- if detailed:
- # Additional validation using conditional config
- conditional_config = ConditionalConfig()
- _is_valid, warnings = conditional_config.validate_against_system(config)
-
- if warnings:
- click.echo(_("Warnings:"))
- for warning in warnings:
- click.echo(_(" ⚠ {warning}").format(warning=warning))
- else:
- click.echo(_("✓ No system compatibility warnings"))
-
- except Exception as e: # pragma: no cover - Error handling for validation failures
- click.echo(
- _("✗ Configuration validation failed: {e}").format(e=e)
- ) # pragma: no cover
- raise click.ClickException(str(e)) from e # pragma: no cover
-
-
-@config_extended.command("list-templates")
+@config.command("list-templates")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["table", "json"]),
default="table",
@@ -1029,9 +1014,10 @@ def list_templates_cmd(format_: str):
raise click.ClickException(str(e)) from e # pragma: no cover
-@config_extended.command("list-profiles")
+@config.command("list-profiles")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["table", "json"]),
default="table",
diff --git a/ccbt/cli/config_group.py b/ccbt/cli/config_group.py
new file mode 100644
index 00000000..35481d5f
--- /dev/null
+++ b/ccbt/cli/config_group.py
@@ -0,0 +1,10 @@
+"""Shared Click ``config`` group (avoids circular imports with extended commands)."""
+
+from __future__ import annotations
+
+import click
+
+
+@click.group()
+def config():
+ """Manage configuration commands."""
diff --git a/ccbt/cli/create_torrent.py b/ccbt/cli/create_torrent.py
index 37383b53..ddb77fbb 100644
--- a/ccbt/cli/create_torrent.py
+++ b/ccbt/cli/create_torrent.py
@@ -28,18 +28,21 @@
)
@click.option(
"--v2",
+ "-V",
"format_v2",
is_flag=True,
help="Create v2-only torrent (BEP 52)",
)
@click.option(
"--hybrid",
+ "-Y",
"format_hybrid",
is_flag=True,
help="Create hybrid torrent (v1 + v2 metadata)",
)
@click.option(
"--v1",
+ "-b",
"format_v1",
is_flag=True,
help="Create v1-only torrent (default if none specified)",
@@ -53,6 +56,7 @@
)
@click.option(
"--web-seed",
+ "-w",
multiple=True,
type=str,
help="Web seed URL (can specify multiple times)",
@@ -65,17 +69,20 @@
)
@click.option(
"--created-by",
+ "-B",
type=str,
default="ccBitTorrent",
help="Created by field (default: ccBitTorrent)",
)
@click.option(
"--piece-length",
+ "-L",
type=int,
help="Piece length in bytes (must be power of 2, default: auto)",
)
@click.option(
"--private",
+ "-r",
is_flag=True,
help="Mark torrent as private (BEP 27)",
)
diff --git a/ccbt/cli/daemon_commands.py b/ccbt/cli/daemon_commands.py
index f747e58f..4d01548a 100644
--- a/ccbt/cli/daemon_commands.py
+++ b/ccbt/cli/daemon_commands.py
@@ -27,7 +27,7 @@
logger = get_logger(__name__)
console = Console()
-# CRITICAL FIX: Suppress Windows ProactorEventLoop cleanup warnings
+# Note: Suppress Windows ProactorEventLoop cleanup warnings
# This is a known Python bug (https://bugs.python.org/issue39232) where
# ProactorEventLoop cleanup raises AttributeError for _ssock during __del__
# The error occurs during garbage collection and doesn't affect functionality
@@ -84,6 +84,7 @@ def daemon():
@daemon.command("start")
+@click.pass_context
@click.option(
"--foreground",
"-f",
@@ -98,11 +99,13 @@ def daemon():
)
@click.option(
"--port",
+ "-p",
type=int,
help=_("Override IPC server port"),
)
@click.option(
"--generate-api-key",
+ "-K",
"regenerate_api_key",
is_flag=True,
help=_("Generate new API key"),
@@ -125,6 +128,7 @@ def daemon():
)
@click.option(
"--no-wait",
+ "-B",
"--background-only",
is_flag=True,
help=_(
@@ -138,6 +142,7 @@ def daemon():
help=_("Disable splash screen (useful for debugging)"),
)
def start(
+ ctx: click.Context,
foreground: bool,
config: Optional[str],
port: Optional[int],
@@ -149,7 +154,10 @@ def start(
no_splash: bool,
) -> None:
"""Start the daemon process."""
- from ccbt.cli.verbosity import VerbosityManager
+ from ccbt.cli.verbosity import (
+ VerbosityManager,
+ apply_cli_verbosity_to_observability,
+ )
# Combine -v count with --vv and --vvv flags
if vvv:
@@ -157,14 +165,21 @@ def start(
elif vv:
verbose = max(verbose, 2) # --vv is equivalent to -vv
+ parent_verbosity = 0
+ if ctx.obj:
+ parent_verbosity = int(ctx.obj.get("verbosity", 0) or 0)
+ merged_verbosity = max(parent_verbosity, verbose)
+
start_time = time.time()
- verbosity = VerbosityManager.from_count(verbose)
+ verbosity = VerbosityManager.from_count(merged_verbosity)
# Initialize config
if verbosity.is_verbose():
console.print(_("[cyan]Initializing configuration...[/cyan]"))
config_manager = init_config(config)
cfg = config_manager.config
+ if hasattr(cfg, "observability"):
+ apply_cli_verbosity_to_observability(cfg.observability, merged_verbosity)
# Ensure daemon config exists
daemon_config_created = False
@@ -284,12 +299,12 @@ def start(
detector = get_detector()
if detector.should_show_splash("daemon.start"):
splash_manager = SplashManager.from_verbosity_count(
- verbose, console=console
+ merged_verbosity, console=console
)
expected_duration = detector.get_expected_duration("daemon.start")
# Update splash message to indicate daemon is starting
with contextlib.suppress(Exception):
- splash_manager.update_progress_message("Starting daemon process...")
+ logger.debug("Starting daemon process...")
# Start splash screen in background thread
def run_splash():
@@ -298,7 +313,6 @@ def run_splash():
splash_manager.show_splash_for_task(
task_name="daemon start",
max_duration=expected_duration,
- show_progress=True,
)
)
@@ -324,7 +338,7 @@ async def _run_foreground() -> None:
await daemon_main.run()
try:
- # CRITICAL FIX: Use asyncio.run() - it properly handles KeyboardInterrupt
+ # Note: Use asyncio.run() - it properly handles KeyboardInterrupt
# The daemon's run() method also catches KeyboardInterrupt and ensures cleanup
# On Windows, asyncio.run() should properly propagate KeyboardInterrupt
asyncio.run(_run_foreground())
@@ -344,7 +358,7 @@ async def _run_foreground() -> None:
"Shutdown event set from CLI KeyboardInterrupt handler"
)
- # CRITICAL FIX: If stop() wasn't called yet (event loop was cancelled before handler ran),
+ # Note: If stop() wasn't called yet (event loop was cancelled before handler ran),
# try to ensure shutdown completes in a new event loop
if not daemon_main_ref.is_stopping:
try:
@@ -406,12 +420,12 @@ async def _ensure_shutdown() -> None:
detector = get_detector()
if detector.should_show_splash("daemon.start"):
splash_manager = SplashManager.from_verbosity_count(
- verbose, console=console
+ merged_verbosity, console=console
)
expected_duration = detector.get_expected_duration("daemon.start")
# Update splash message to indicate daemon is starting
with contextlib.suppress(Exception):
- splash_manager.update_progress_message("Starting daemon process...")
+ logger.debug("Starting daemon process...")
# Start splash screen in background thread
def run_splash():
@@ -420,7 +434,6 @@ def run_splash():
splash_manager.show_splash_for_task(
task_name="daemon start",
max_duration=expected_duration,
- show_progress=True,
)
)
@@ -432,6 +445,8 @@ def run_splash():
extra_args: list[str] = []
if config_manager.config_file and config_manager.config_file.exists():
extra_args.extend(["--config", str(config_manager.config_file)])
+ if merged_verbosity:
+ extra_args.append(f"-{'v' * merged_verbosity}")
pid = daemon_manager.start(
foreground=False,
extra_args=extra_args if extra_args else None,
@@ -487,9 +502,7 @@ def run_splash():
# Update splash message to indicate initialization
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.update_progress_message(
- "Initializing daemon components..."
- )
+ logger.debug("Initializing daemon components...")
if verbosity.is_verbose():
console.print(_("[cyan]Waiting for daemon to be ready...[/cyan]"))
@@ -521,9 +534,7 @@ def run_splash():
# Update splash screen message to indicate initialization complete
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.update_progress_message(
- "Daemon initialization complete!"
- ) # Ignore errors updating splash
+ logger.debug("Daemon initialization complete!")
# Small additional delay to ensure "Daemon initialization complete" message has been logged
time.sleep(0.5)
console.print(
@@ -534,7 +545,7 @@ def run_splash():
# Clear splash screen only after daemon initialization is fully complete
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.clear_progress_messages()
+ splash_manager.stop_splash()
else:
console.print(
_(
@@ -615,9 +626,7 @@ async def _check_daemon_loop() -> bool:
# Update splash to indicate waiting for full initialization
if splash_manager and last_stage != "waiting":
try:
- splash_manager.update_progress_message(
- "Waiting for daemon to be ready..."
- )
+ logger.debug("Waiting for daemon to be ready...")
last_stage = "waiting"
except Exception:
pass
@@ -632,7 +641,7 @@ async def _check_daemon_loop() -> bool:
# Update splash message during wait
if splash_manager and last_stage != "checking":
try:
- splash_manager.update_progress_message("Checking daemon status...")
+ logger.debug("Checking daemon status...")
last_stage = "checking"
except Exception:
pass
@@ -823,7 +832,7 @@ async def _wait_loop() -> bool:
# Update splash screen with stage description
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.update_progress_message(stage_desc)
+ logger.debug(stage_desc)
if progress and task is not None:
progress.update(task, description=stage_desc)
@@ -834,7 +843,7 @@ async def _wait_loop() -> bool:
# Update splash to indicate waiting for full initialization
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.update_progress_message(
+ logger.debug(
"Waiting for daemon initialization to complete..."
)
# Small delay to ensure daemon has fully initialized (including "Daemon initialization complete" message)
@@ -884,11 +893,13 @@ async def _wait_loop() -> bool:
@daemon.command("exit")
@click.option(
"--force",
+ "-f",
is_flag=True,
help=_("Force kill without graceful shutdown"),
)
@click.option(
"--timeout",
+ "-t",
type=float,
default=30.0,
help=_("Shutdown timeout in seconds"),
@@ -952,6 +963,12 @@ async def _shutdown_daemon() -> bool:
).format(elapsed=elapsed),
)
time.sleep(0.5)
+ else:
+ click.echo(
+ _(
+ "Daemon rejected graceful shutdown request, using signal fallback..."
+ )
+ )
except Exception as e:
logger.debug(_("Error sending shutdown request: %s"), e)
click.echo(_("Could not send shutdown request, using signal..."))
diff --git a/ccbt/cli/diagnostics.py b/ccbt/cli/diagnostics.py
index 9f37efc3..df3545ff 100644
--- a/ccbt/cli/diagnostics.py
+++ b/ccbt/cli/diagnostics.py
@@ -22,7 +22,7 @@
def run_diagnostics(config_manager: ConfigManager, console: Console) -> None:
"""Run diagnostic checks for network connectivity and configuration."""
- # CRITICAL FIX: Check for daemon PID file BEFORE creating local session
+ # Note: Check for daemon PID file BEFORE creating local session
# If PID file exists, we MUST prevent local session to avoid port conflicts
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -120,7 +120,7 @@ def run_diagnostics(config_manager: ConfigManager, console: Console) -> None:
console.print(_("\n[yellow]6. Session Initialization Test[/yellow]"))
try:
- # CRITICAL FIX: Use safe local session creation helper
+ # Note: Use safe local session creation helper
from ccbt.cli.main import _ensure_local_session_safe
session = asyncio.run(_ensure_local_session_safe(_force_local=True))
diff --git a/ccbt/cli/downloads.py b/ccbt/cli/downloads.py
index 9197f1af..553cb92d 100644
--- a/ccbt/cli/downloads.py
+++ b/ccbt/cli/downloads.py
@@ -559,14 +559,12 @@ async def start_basic_magnet_download(
if hasattr(torrent_status, "status"):
current_status = torrent_status.status
current_progress = getattr(torrent_status, "progress", 0.0)
- connected_peers = getattr(torrent_status, "num_peers", 0)
+ connected_peers = int(getattr(torrent_status, "connected_peers", 0))
download_rate = getattr(torrent_status, "download_rate", 0.0)
elif isinstance(torrent_status, dict):
current_status = torrent_status.get("status", "unknown")
current_progress = torrent_status.get("progress", 0.0)
- connected_peers = torrent_status.get(
- "connected_peers", torrent_status.get("num_peers", 0)
- )
+ connected_peers = int(torrent_status.get("connected_peers", 0) or 0)
download_rate = torrent_status.get("download_rate", 0.0)
else:
current_status = "unknown"
@@ -596,7 +594,7 @@ async def start_basic_magnet_download(
)
)
- # CRITICAL FIX: Add user-facing warning if no peers connect after reasonable time
+ # Note: Add user-facing warning if no peers connect after reasonable time
if current_status == "downloading" and connected_peers == 0:
import time
@@ -686,7 +684,7 @@ async def start_basic_magnet_download(
).format(error=e)
)
- # CRITICAL FIX: Ensure session is properly stopped on KeyboardInterrupt
+ # Note: Ensure session is properly stopped on KeyboardInterrupt
# This prevents "Unclosed client session" warnings
try:
await session.stop()
@@ -698,7 +696,7 @@ async def start_basic_magnet_download(
)
raise
finally:
- # CRITICAL FIX: Always try to stop session in finally block
+ # Note: Always try to stop session in finally block
# This ensures cleanup even if an exception occurs
try:
result = await executor.execute(
diff --git a/ccbt/cli/filter_commands.py b/ccbt/cli/filter_commands.py
index 30feeb6e..52f3a3f2 100644
--- a/ccbt/cli/filter_commands.py
+++ b/ccbt/cli/filter_commands.py
@@ -24,9 +24,19 @@ def filter_group() -> None:
@filter_group.command("add")
@click.argument("ip_range")
@click.option(
- "--mode", type=click.Choice(["block", "allow"]), default="block", help="Filter mode"
+ "--mode",
+ "-m",
+ type=click.Choice(["block", "allow"]),
+ default="block",
+ help="Filter mode",
+)
+@click.option(
+ "--priority",
+ "-p",
+ type=int,
+ default=0,
+ help="Rule priority (higher wins)",
)
-@click.option("--priority", type=int, default=0, help="Rule priority (higher wins)")
@click.pass_context
def filter_add(ctx, ip_range: str, mode: str, priority: int) -> None:
"""Add IP range to filter.
@@ -130,6 +140,7 @@ async def _remove_rule() -> None:
@filter_group.command("list")
@click.option(
"--format",
+ "-f",
type=click.Choice(["table", "json"]),
default="table",
help="Output format",
@@ -201,6 +212,7 @@ async def _list_rules() -> None:
@click.argument("file_path", type=click.Path(exists=True))
@click.option(
"--mode",
+ "-m",
type=click.Choice(["block", "allow"]),
default=None,
help="Filter mode (uses default if not specified)",
diff --git a/ccbt/cli/interactive.py b/ccbt/cli/interactive.py
index f0a73260..65029155 100644
--- a/ccbt/cli/interactive.py
+++ b/ccbt/cli/interactive.py
@@ -20,6 +20,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Any, Optional
+from ccbt.cli.ssl_posture import is_strict_ssl_posture
from ccbt.i18n import _
# region agent log
@@ -114,6 +115,7 @@ def _agent_debug_log(
logger = logging.getLogger(__name__)
+
if TYPE_CHECKING: # pragma: no cover - TYPE_CHECKING imports not executed at runtime
from ccbt.session.session import AsyncSessionManager
@@ -510,6 +512,16 @@ def create_status_panel(self) -> Panel:
),
style="white",
)
+ status_text.append("\n")
+ status_text.append(
+ _("Tip: full option catalog and file merge → "),
+ style="dim",
+ )
+ status_text.append("btbt config describe", style="cyan")
+ status_text.append(" / ", style="dim")
+ status_text.append("btbt config apply", style="cyan")
+ status_text.append(" / ", style="dim")
+ status_text.append("btbt config schema", style="cyan")
return Panel(status_text, title=_("Status"))
@@ -1610,7 +1622,7 @@ async def _show_disk_stats(self) -> None:
if self.session and hasattr(self.session, "disk_io_manager"):
disk_io = self.session.disk_io_manager
else:
- # Fallback to deprecated singleton
+ # Fallback to compatibility singleton
from ccbt.storage.disk_io_init import get_disk_io_manager
with contextlib.suppress(Exception):
@@ -1980,12 +1992,49 @@ async def _show_network_config(self) -> None:
)
table.add_row(
"Enable Encryption",
- "Yes" if getattr(cfg.network, "enable_encryption", False) else "No",
+ "Yes" if getattr(cfg.security, "enable_encryption", False) else "No",
"Enable protocol encryption",
)
+ security = getattr(cfg, "security", None)
+ if security is not None:
+ ssl_cfg = getattr(security, "ssl", None)
+ table.add_row(
+ "Tracker SSL",
+ "Yes" if getattr(ssl_cfg, "enable_ssl_trackers", False) else "No",
+ "Use HTTPS for tracker communication",
+ )
+ table.add_row(
+ "Peer SSL",
+ "Yes" if getattr(ssl_cfg, "enable_ssl_peers", False) else "No",
+ "Enable experimental peer TLS",
+ )
+ table.add_row(
+ "SSL Verify Certificates",
+ "Enabled"
+ if getattr(ssl_cfg, "ssl_verify_certificates", True)
+ else "Disabled",
+ "Verify TLS certificates for tracker/peer channels",
+ )
+ table.add_row(
+ "Allow Insecure Peers",
+ "Yes" if getattr(ssl_cfg, "ssl_allow_insecure_peers", False) else "No",
+ "Allow peers with invalid certificates",
+ )
self.console.print(table)
+ if (
+ security is not None
+ and ssl_cfg is not None
+ and is_strict_ssl_posture(ssl_cfg)
+ ):
+ self.console.print(
+ _(
+ "[yellow]Warning: certificate verification is disabled while SSL is"
+ " in strict posture[/yellow]"
+ )
+ )
+
async def _show_network_stats(self) -> None:
"""Display network I/O statistics."""
from rich.table import Table
@@ -2082,7 +2131,7 @@ async def _show_network_optimizations(self) -> None:
cfg = get_config()
table = Table(title=_("Network Optimization Recommendations"))
table.add_column("Setting", style="cyan")
- table.add_column("Current", style="yellow")
+ table.add_column(_("Current"), style="yellow")
table.add_column("Recommended", style="green")
table.add_column("Reason", style="dim")
@@ -2844,7 +2893,7 @@ async def cmd_config_import(self, args: list[str]) -> None:
self.console.print(_("[green]Imported configuration[/green]"))
async def cmd_config_schema(self, args: list[str]) -> None:
- """Show configuration JSON schema.
+ """Show configuration JSON schema (same data as ``btbt config schema``).
Usage:
config_schema [model]
@@ -2860,16 +2909,25 @@ async def cmd_config_schema(self, args: list[str]) -> None:
self.console.print_json(data=json.loads(json.dumps(data)))
async def cmd_config(self, args: list[str]) -> None:
- """Show or modify configuration at runtime.
+ """Show or modify in-memory configuration at runtime.
Usage:
config show [section|key.path]
config get
config set
config reload
+
+ For every option path, defaults, and validated TOML edits, use the shell:
+ ``btbt config describe``, ``btbt config set``, ``btbt config apply``,
+ ``btbt config import``.
"""
if not args:
- self.console.print(_("Usage: config [show|get|set|reload] ..."))
+ self.console.print(
+ _(
+ "Usage: config [show|get|set|reload] ...\n"
+ "Shell: btbt config describe | apply | import | schema"
+ )
+ )
return
sub = args[0]
cm = ConfigManager(None)
diff --git a/ccbt/cli/ipfs_commands.py b/ccbt/cli/ipfs_commands.py
index ee3ecfeb..c23f3a0b 100644
--- a/ccbt/cli/ipfs_commands.py
+++ b/ccbt/cli/ipfs_commands.py
@@ -73,7 +73,7 @@ async def _get_ipfs_protocol() -> Optional[Any]: # Optional[IPFSProtocol]
logger.exception("Failed to get IPFS protocol from session")
# Fallback: create temporary session if executor not available
- # CRITICAL FIX: Use safe local session creation helper
+ # Note: Use safe local session creation helper
try:
from ccbt.cli.main import _ensure_local_session_safe
@@ -100,8 +100,13 @@ async def _get_ipfs_protocol() -> Optional[Any]: # Optional[IPFSProtocol]
@click.command("ipfs-add")
@click.argument("path", type=click.Path(exists=True, path_type=Path))
-@click.option("--pin/--no-pin", default=False, help="Pin content after adding")
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option(
+ "-i/--pin/--no-pin",
+ "pin",
+ default=False,
+ help="Pin content after adding",
+)
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_add(path: Path, pin: bool, json_output: bool) -> None:
"""Add file or directory to IPFS."""
console = Console()
@@ -147,7 +152,7 @@ async def _add() -> None:
@click.option(
"--output", "-o", type=click.Path(path_type=Path), help="Output file path"
)
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_get(cid: str, output: Optional[Path], json_output: bool) -> None:
"""Get content from IPFS by CID."""
console = Console()
@@ -187,7 +192,7 @@ async def _get() -> None:
@click.command("ipfs-pin")
@click.argument("cid", type=str)
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_pin(cid: str, json_output: bool) -> None:
"""Pin content in IPFS."""
console = Console()
@@ -213,7 +218,7 @@ async def _pin() -> None:
@click.command("ipfs-unpin")
@click.argument("cid", type=str)
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_unpin(cid: str, json_output: bool) -> None:
"""Unpin content in IPFS."""
console = Console()
@@ -239,8 +244,14 @@ async def _unpin() -> None:
@click.command("ipfs-stats")
@click.argument("cid", type=str, required=False)
-@click.option("--all", "all_stats", is_flag=True, help="Show stats for all content")
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option(
+ "-a",
+ "--all",
+ "all_stats",
+ is_flag=True,
+ help="Show stats for all content",
+)
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_stats(cid: Optional[str], all_stats: bool, json_output: bool) -> None:
"""Show IPFS content statistics."""
console = Console()
@@ -293,7 +304,7 @@ async def _stats() -> None:
@click.command("ipfs-peers")
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_peers(json_output: bool) -> None:
"""List connected IPFS peers."""
console = Console()
@@ -337,7 +348,7 @@ async def _peers() -> None:
@click.command("ipfs-content")
-@click.option("--json", "json_output", is_flag=True, help="Output as JSON")
+@click.option("-j", "--json", "json_output", is_flag=True, help="Output as JSON")
def ipfs_content(json_output: bool) -> None:
"""List all IPFS content."""
console = Console()
diff --git a/ccbt/cli/main.py b/ccbt/cli/main.py
index 32a5156d..5e44235c 100644
--- a/ccbt/cli/main.py
+++ b/ccbt/cli/main.py
@@ -1,7 +1,5 @@
"""Enhanced CLI for ccBitTorrent.
-from __future__ import annotations
-
Provides rich CLI interface with:
- Interactive TUI
- Progress bars
@@ -27,8 +25,12 @@
from ccbt.cli.advanced_commands import recover as recover_cmd
from ccbt.cli.advanced_commands import security as security_cmd
from ccbt.cli.advanced_commands import test as test_cmd
+from ccbt.cli.auth_commands import auth as auth_group
+from ccbt.cli.cli_option_sets import (
+ DOWNLOAD_MAGNET_SHARED_OPTIONS,
+ compose_click_options,
+)
from ccbt.cli.config_commands import config as config_group
-from ccbt.cli.config_commands_extended import config_extended
from ccbt.cli.create_torrent import create_torrent
from ccbt.cli.daemon_commands import daemon as daemon_group
from ccbt.cli.downloads import (
@@ -56,12 +58,14 @@
from ccbt.cli.queue_commands import queue as queue_group
from ccbt.cli.scrape_commands import scrape as scrape_group
from ccbt.cli.ssl_commands import ssl as ssl_group
+from ccbt.cli.ssl_posture import is_strict_ssl_posture
from ccbt.cli.torrent_commands import dht as dht_group
from ccbt.cli.torrent_commands import global_controls as global_controls_group
from ccbt.cli.torrent_commands import peer as peer_group
from ccbt.cli.torrent_commands import pex as pex_group
from ccbt.cli.torrent_commands import torrent as torrent_control_group
from ccbt.config.config import Config, ConfigManager, get_config, init_config
+from ccbt.config.env_bootstrap import maybe_load_dotenv_from_env
from ccbt.daemon.daemon_manager import DaemonManager
from ccbt.daemon.ipc_client import IPCClient # type: ignore[attr-defined]
from ccbt.i18n import _
@@ -336,7 +340,7 @@ async def _route_to_daemon_if_running(
True if routed to daemon, False if daemon not running
"""
- # CRITICAL FIX: Check PID file existence directly before attempting os.kill()
+ # Note: Check PID file existence directly before attempting os.kill()
# This avoids Windows-specific os.kill() errors that can cause false negatives
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -361,7 +365,7 @@ async def _route_to_daemon_if_running(
# Don't set daemon_running = False here - we'll check via IPC instead
# The IPC connection check is the authoritative way to verify daemon is running
- # CRITICAL FIX: If PID file exists, we MUST attempt IPC connection
+ # Note: If PID file exists, we MUST attempt IPC connection
# Don't skip IPC check just because is_running() failed on Windows
# The IPC connection is the definitive test of whether the daemon is accessible
if not pid_file_exists and not daemon_running:
@@ -395,7 +399,7 @@ async def _route_to_daemon_if_running(
)
client = IPCClient(api_key=api_key, base_url=base_url)
- # CRITICAL FIX: Verify daemon is actually accessible before routing
+ # Note: Verify daemon is actually accessible before routing
# Increased timeout to 30 seconds to account for slow daemon startup (NAT discovery, DHT bootstrap, etc.)
# Initial wait to give daemon time to start IPC server after PID file is written
initial_wait = 1.0
@@ -526,9 +530,9 @@ async def _route_to_daemon_if_running(
raise click.ClickException(error_msg)
return False
- # CRITICAL FIX: Perform the requested operation using executor
+ # Note: Perform the requested operation using executor
# Wrap in try-except to ensure client is properly closed even on errors
- # CRITICAL FIX: Use ExecutorManager to ensure consistent executor creation
+ # Note: Use ExecutorManager to ensure consistent executor creation
from ccbt.executor.manager import ExecutorManager
executor_manager = ExecutorManager.get_instance()
@@ -633,7 +637,7 @@ async def _route_to_daemon_if_running(
# Re-raise ClickException (these are user-facing errors about daemon state)
raise
except Exception as e:
- # CRITICAL FIX: Distinguish between connection errors and other errors
+ # Note: Distinguish between connection errors and other errors
error_type = type(e).__name__
error_str = str(e)
is_connection_error = (
@@ -681,7 +685,7 @@ async def _route_to_daemon_if_running(
return False
finally:
- # CRITICAL FIX: Always close client to prevent resource leaks
+ # Note: Always close client to prevent resource leaks
if client:
try:
await client.close()
@@ -815,7 +819,7 @@ async def _get_executor() -> tuple[Optional[Any], bool]:
raise click.ClickException(_(timeout_msg))
# Daemon is accessible - create executor via ExecutorManager
- # CRITICAL FIX: Use ExecutorManager to ensure consistent executor creation
+ # Note: Use ExecutorManager to ensure consistent executor creation
# This prevents duplicate executors and ensures proper session reference management
# ExecutorManager will create DaemonSessionAdapter internally when ipc_client is provided
from ccbt.executor.manager import ExecutorManager
@@ -947,6 +951,7 @@ def _apply_cli_overrides(cfg_mgr: ConfigManager, options: dict[str, Any]) -> Non
cfg = cfg_mgr.config
_apply_network_overrides(cfg, options)
+ _apply_ssl_overrides(cfg, options)
_apply_discovery_overrides(cfg, options)
_apply_strategy_overrides(cfg, options)
_apply_disk_overrides(cfg, options)
@@ -989,9 +994,9 @@ def _apply_network_overrides(cfg: Config, options: dict[str, Any]) -> None:
if options.get("disable_utp"):
cfg.network.enable_utp = False
if options.get("enable_encryption"):
- cfg.network.enable_encryption = True
+ cfg.security.enable_encryption = True
if options.get("disable_encryption"):
- cfg.network.enable_encryption = False
+ cfg.security.enable_encryption = False
if options.get("tcp_nodelay"):
cfg.network.tcp_nodelay = True
if options.get("no_tcp_nodelay"):
@@ -1103,6 +1108,22 @@ def _apply_strategy_overrides(cfg: Config, options: dict[str, Any]) -> None:
) # type: ignore[attr-defined]
if options.get("unchoke_interval") is not None:
cfg.network.unchoke_interval = float(options["unchoke_interval"]) # type: ignore[attr-defined]
+ if options.get("peer_choked_hard_timeout_seconds") is not None:
+ cfg.network.peer_choked_hard_timeout_seconds = float(
+ options["peer_choked_hard_timeout_seconds"],
+ )
+ if options.get("peer_choked_anchor_timeout_seconds") is not None:
+ cfg.network.peer_choked_anchor_timeout_seconds = float(
+ options["peer_choked_anchor_timeout_seconds"],
+ )
+ if options.get("peer_choked_solo_grace_seconds") is not None:
+ cfg.network.peer_choked_solo_grace_seconds = float(
+ options["peer_choked_solo_grace_seconds"],
+ )
+ if options.get("peer_choked_solo_grace_zero_bytes_cap_seconds") is not None:
+ cfg.network.peer_choked_solo_grace_zero_bytes_cap_seconds = float(
+ options["peer_choked_solo_grace_zero_bytes_cap_seconds"],
+ )
if options.get("sequential_window_size") is not None:
cfg.strategy.sequential_window = int(options["sequential_window_size"]) # type: ignore[attr-defined]
if options.get("sequential_priority_files") is not None:
@@ -1213,6 +1234,14 @@ def _apply_ssl_overrides(cfg: Config, options: dict[str, Any]) -> None:
logger.warning("SSL client key path does not exist: %s", key_path)
if options.get("no_ssl_verify"):
cfg.security.ssl.ssl_verify_certificates = False
+ logger.warning(
+ "SSL certificate verification disabled (--no-ssl-verify). "
+ "HTTPS tracker connections will not validate server certificates.",
+ )
+ if is_strict_ssl_posture(cfg.security.ssl):
+ logger.warning(
+ "Strict SSL posture requested while verification is disabled."
+ )
if options.get("ssl_protocol_version"):
cfg.security.ssl.ssl_protocol_version = options["ssl_protocol_version"]
@@ -1293,6 +1322,7 @@ def _apply_protocol_v2_overrides(cfg: Config, options: dict[str, Any]) -> None:
@click.pass_context
def cli(ctx, config, verbose, debug):
"""CcBitTorrent - High-performance BitTorrent client."""
+ maybe_load_dotenv_from_env()
ctx.ensure_object(dict)
ctx.obj["config"] = config
# Convert debug flag to verbosity count for backward compatibility
@@ -1328,27 +1358,12 @@ def cli(ctx, config, verbose, debug):
"en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu"
).format(current_locale=current_locale)
)
- # Update logging level based on verbosity
+ # Update logging level based on verbosity (survives later init_config)
cfg = config_manager.config
if hasattr(cfg, "observability"):
- from ccbt.models import LogLevel
- from ccbt.utils.logging_config import setup_logging
-
- # Temporarily override log level based on verbosity
- original_log_level = cfg.observability.log_level
-
- # Map verbosity to log level: -v=INFO, -vv/-vvv=DEBUG
- if verbosity_manager.is_debug():
- cfg.observability.log_level = LogLevel.DEBUG
- elif verbosity_manager.is_verbose():
- cfg.observability.log_level = LogLevel.INFO
- # else: keep original level (usually INFO)
-
- # Setup logging with verbosity-aware level
- setup_logging(cfg.observability)
+ from ccbt.cli.verbosity import apply_cli_verbosity_to_observability
- # Restore original log level (verbosity only affects console output)
- cfg.observability.log_level = original_log_level
+ apply_cli_verbosity_to_observability(cfg.observability, verbose)
# docs command removed; docs are maintained in repository
@@ -1364,115 +1379,7 @@ def cli(ctx, config, verbose, debug):
is_flag=True,
help=_("Resume from checkpoint if available"),
)
-@click.option("--no-checkpoint", is_flag=True, help=_("Disable checkpointing"))
-@click.option("--checkpoint-dir", type=click.Path(), help=_("Checkpoint directory"))
-@click.option("--listen-port", type=int, help=_("Listen port"))
-@click.option("--max-peers", type=int, help=_("Maximum global peers"))
-@click.option("--max-peers-per-torrent", type=int, help=_("Maximum peers per torrent"))
-@click.option("--pipeline-depth", type=int, help=_("Request pipeline depth"))
-@click.option("--block-size-kib", type=int, help=_("Block size (KiB)"))
-@click.option("--connection-timeout", type=float, help=_("Connection timeout (s)"))
-@click.option("--download-limit", type=int, help=_("Global download limit (KiB/s)"))
-@click.option("--upload-limit", type=int, help=_("Global upload limit (KiB/s)"))
-@click.option("--dht-port", type=int, help=_("DHT port"))
-@click.option("--enable-dht", is_flag=True, help=_("Enable DHT"))
-@click.option("--disable-dht", is_flag=True, help=_("Disable DHT"))
-@click.option(
- "--piece-selection",
- type=click.Choice(["round_robin", "rarest_first", "sequential"]),
-)
-@click.option("--endgame-threshold", type=float, help=_("Endgame threshold (0..1)"))
-@click.option("--hash-workers", type=int, help=_("Hash verification workers"))
-@click.option("--disk-workers", type=int, help=_("Disk I/O workers"))
-@click.option("--use-mmap", is_flag=True, help=_("Use memory mapping"))
-@click.option("--no-mmap", is_flag=True, help=_("Disable memory mapping"))
-@click.option("--mmap-cache-mb", type=int, help=_("MMap cache size (MB)"))
-@click.option("--write-batch-kib", type=int, help=_("Write batch size (KiB)"))
-@click.option("--write-buffer-kib", type=int, help=_("Write buffer size (KiB)"))
-@click.option("--preallocate", type=click.Choice(["none", "sparse", "full"]))
-@click.option("--sparse-files", is_flag=True, help=_("Enable sparse files"))
-@click.option("--no-sparse-files", is_flag=True, help=_("Disable sparse files"))
-@click.option(
- "--enable-io-uring",
- is_flag=True,
- help=_("Enable io_uring on Linux if available"),
-)
-@click.option("--disable-io-uring", is_flag=True, help=_("Disable io_uring usage"))
-@click.option(
- "--direct-io",
- is_flag=True,
- help=_("Enable direct I/O for writes when supported"),
-)
-@click.option(
- "--sync-writes", is_flag=True, help=_("Enable fsync after batched writes")
-)
-@click.option(
- "--log-level",
- type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
-)
-@click.option("--enable-metrics", is_flag=True, help=_("Enable metrics"))
-@click.option("--disable-metrics", is_flag=True, help=_("Disable metrics"))
-@click.option("--metrics-port", type=int, help=_("Metrics port"))
-@click.option("--enable-ipv6", is_flag=True, help=_("Enable IPv6"))
-@click.option("--disable-ipv6", is_flag=True, help=_("Disable IPv6"))
-@click.option("--enable-tcp", is_flag=True, help=_("Enable TCP transport"))
-@click.option("--disable-tcp", is_flag=True, help=_("Disable TCP transport"))
-@click.option("--enable-utp", is_flag=True, help=_("Enable uTP transport"))
-@click.option("--disable-utp", is_flag=True, help=_("Disable uTP transport"))
-@click.option("--enable-encryption", is_flag=True, help=_("Enable protocol encryption"))
-@click.option(
- "--disable-encryption", is_flag=True, help=_("Disable protocol encryption")
-)
-@click.option("--tcp-nodelay", is_flag=True, help=_("Enable TCP_NODELAY"))
-@click.option("--no-tcp-nodelay", is_flag=True, help=_("Disable TCP_NODELAY"))
-@click.option("--socket-rcvbuf-kib", type=int, help=_("Socket receive buffer (KiB)"))
-@click.option("--socket-sndbuf-kib", type=int, help=_("Socket send buffer (KiB)"))
-@click.option("--listen-interface", type=str, help=_("Listen interface"))
-@click.option("--peer-timeout", type=float, help=_("Peer timeout (s)"))
-@click.option("--dht-timeout", type=float, help=_("DHT timeout (s)"))
-@click.option("--min-block-size-kib", type=int, help=_("Minimum block size (KiB)"))
-@click.option("--max-block-size-kib", type=int, help=_("Maximum block size (KiB)"))
-@click.option("--enable-http-trackers", is_flag=True, help=_("Enable HTTP trackers"))
-@click.option("--disable-http-trackers", is_flag=True, help=_("Disable HTTP trackers"))
-@click.option("--enable-udp-trackers", is_flag=True, help=_("Enable UDP trackers"))
-@click.option("--disable-udp-trackers", is_flag=True, help=_("Disable UDP trackers"))
-@click.option(
- "--tracker-announce-interval",
- type=float,
- help=_("Tracker announce interval (s)"),
-)
-@click.option(
- "--tracker-scrape-interval",
- type=float,
- help=_("Tracker scrape interval (s)"),
-)
-@click.option("--pex-interval", type=float, help=_("PEX interval (s)"))
-@click.option("--endgame-duplicates", type=int, help=_("Endgame duplicate requests"))
-@click.option("--streaming-mode", is_flag=True, help=_("Enable streaming mode"))
-@click.option("--first-piece-priority", is_flag=True, help=_("Prioritize first piece"))
-@click.option("--last-piece-priority", is_flag=True, help=_("Prioritize last piece"))
-@click.option(
- "--optimistic-unchoke-interval",
- type=float,
- help=_("Optimistic unchoke interval (s)"),
-)
-@click.option("--unchoke-interval", type=float, help=_("Unchoke interval (s)"))
-@click.option("--metrics-interval", type=float, help=_("Metrics interval (s)"))
-@click.option(
- "--enable-v2", "enable_v2", is_flag=True, help=_("Enable Protocol v2 (BEP 52)")
-)
-@click.option(
- "--disable-v2", "disable_v2", is_flag=True, help=_("Disable Protocol v2 (BEP 52)")
-)
-@click.option(
- "--prefer-v2",
- "prefer_v2",
- is_flag=True,
- help=_("Prefer Protocol v2 when available"),
-)
-@click.option(
- "--v2-only", "v2_only", is_flag=True, help=_("Use Protocol v2 only (disable v1)")
-)
+@compose_click_options(*DOWNLOAD_MAGNET_SHARED_OPTIONS)
@click.pass_context
def download(
ctx,
@@ -1489,7 +1396,7 @@ def download(
console = Console()
try:
- # CRITICAL FIX: Always check for daemon PID file FIRST before calling _get_executor()
+ # Note: Always check for daemon PID file FIRST before calling _get_executor()
# This prevents any possibility of creating a local session when daemon is running
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -1551,7 +1458,7 @@ async def _add_torrent_to_daemon():
asyncio.run(_add_torrent_to_daemon())
return
- # CRITICAL FIX: Double-check daemon PID file before creating local session
+ # Note: Double-check daemon PID file before creating local session
# This is a safety check - if we reach here, PID file should NOT exist
# (because we checked it at the start and _get_executor() would have raised if it existed)
if pid_file_exists:
@@ -1559,7 +1466,7 @@ async def _add_torrent_to_daemon():
raise click.ClickException(_(DAEMON_CRITICAL_ERROR_MSG))
# No daemon running - create local session and executor
- # CRITICAL FIX: Use ExecutorManager for consistency, even for local sessions
+ # Note: Use ExecutorManager for consistency, even for local sessions
from ccbt.executor.manager import ExecutorManager
# Load configuration
@@ -1577,12 +1484,12 @@ async def _add_torrent_to_daemon():
# Create session (only when daemon is NOT running)
session = AsyncSessionManager(".")
- # CRITICAL FIX: Start session immediately to initialize NAT manager, TCP server, and port bindings
+ # Note: Start session immediately to initialize NAT manager, TCP server, and port bindings
# This ensures components use configured ports instead of random ports
# NOTE: This only runs when daemon is confirmed NOT running - no port conflicts possible
asyncio.run(session.start())
- # CRITICAL FIX: Use ExecutorManager to ensure consistent executor creation
+ # Note: Use ExecutorManager to ensure consistent executor creation
# This prevents duplicate executors and ensures proper session reference management
executor_manager = ExecutorManager.get_instance()
executor = executor_manager.get_executor(session_manager=session)
@@ -1711,6 +1618,7 @@ async def _add_torrent_to_daemon():
@click.option("--interactive", "-i", is_flag=True, help=_("Start interactive mode"))
@click.option(
"--select-files",
+ "-F",
is_flag=True,
help=_("Wait for metadata and prompt for file selection (interactive only)"),
)
@@ -1720,115 +1628,7 @@ async def _add_torrent_to_daemon():
is_flag=True,
help=_("Resume from checkpoint if available"),
)
-@click.option("--no-checkpoint", is_flag=True, help=_("Disable checkpointing"))
-@click.option("--checkpoint-dir", type=click.Path(), help=_("Checkpoint directory"))
-@click.option("--listen-port", type=int, help=_("Listen port"))
-@click.option("--max-peers", type=int, help=_("Maximum global peers"))
-@click.option("--max-peers-per-torrent", type=int, help=_("Maximum peers per torrent"))
-@click.option("--pipeline-depth", type=int, help=_("Request pipeline depth"))
-@click.option("--block-size-kib", type=int, help=_("Block size (KiB)"))
-@click.option("--connection-timeout", type=float, help=_("Connection timeout (s)"))
-@click.option("--download-limit", type=int, help=_("Global download limit (KiB/s)"))
-@click.option("--upload-limit", type=int, help=_("Global upload limit (KiB/s)"))
-@click.option("--dht-port", type=int, help=_("DHT port"))
-@click.option("--enable-dht", is_flag=True, help=_("Enable DHT"))
-@click.option("--disable-dht", is_flag=True, help=_("Disable DHT"))
-@click.option(
- "--piece-selection",
- type=click.Choice(["round_robin", "rarest_first", "sequential"]),
-)
-@click.option("--endgame-threshold", type=float, help=_("Endgame threshold (0..1)"))
-@click.option("--hash-workers", type=int, help=_("Hash verification workers"))
-@click.option("--disk-workers", type=int, help=_("Disk I/O workers"))
-@click.option("--use-mmap", is_flag=True, help=_("Use memory mapping"))
-@click.option("--no-mmap", is_flag=True, help=_("Disable memory mapping"))
-@click.option("--mmap-cache-mb", type=int, help=_("MMap cache size (MB)"))
-@click.option("--write-batch-kib", type=int, help=_("Write batch size (KiB)"))
-@click.option("--write-buffer-kib", type=int, help=_("Write buffer size (KiB)"))
-@click.option("--preallocate", type=click.Choice(["none", "sparse", "full"]))
-@click.option("--sparse-files", is_flag=True, help=_("Enable sparse files"))
-@click.option("--no-sparse-files", is_flag=True, help=_("Disable sparse files"))
-@click.option(
- "--enable-io-uring",
- is_flag=True,
- help=_("Enable io_uring on Linux if available"),
-)
-@click.option("--disable-io-uring", is_flag=True, help=_("Disable io_uring usage"))
-@click.option(
- "--direct-io",
- is_flag=True,
- help=_("Enable direct I/O for writes when supported"),
-)
-@click.option(
- "--sync-writes", is_flag=True, help=_("Enable fsync after batched writes")
-)
-@click.option(
- "--log-level",
- type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]),
-)
-@click.option("--enable-metrics", is_flag=True, help=_("Enable metrics"))
-@click.option("--disable-metrics", is_flag=True, help=_("Disable metrics"))
-@click.option("--metrics-port", type=int, help=_("Metrics port"))
-@click.option("--enable-ipv6", is_flag=True, help=_("Enable IPv6"))
-@click.option("--disable-ipv6", is_flag=True, help=_("Disable IPv6"))
-@click.option("--enable-tcp", is_flag=True, help=_("Enable TCP transport"))
-@click.option("--disable-tcp", is_flag=True, help=_("Disable TCP transport"))
-@click.option("--enable-utp", is_flag=True, help=_("Enable uTP transport"))
-@click.option("--disable-utp", is_flag=True, help=_("Disable uTP transport"))
-@click.option("--enable-encryption", is_flag=True, help=_("Enable protocol encryption"))
-@click.option(
- "--disable-encryption", is_flag=True, help=_("Disable protocol encryption")
-)
-@click.option("--tcp-nodelay", is_flag=True, help=_("Enable TCP_NODELAY"))
-@click.option("--no-tcp-nodelay", is_flag=True, help=_("Disable TCP_NODELAY"))
-@click.option("--socket-rcvbuf-kib", type=int, help=_("Socket receive buffer (KiB)"))
-@click.option("--socket-sndbuf-kib", type=int, help=_("Socket send buffer (KiB)"))
-@click.option("--listen-interface", type=str, help=_("Listen interface"))
-@click.option("--peer-timeout", type=float, help=_("Peer timeout (s)"))
-@click.option("--dht-timeout", type=float, help=_("DHT timeout (s)"))
-@click.option("--min-block-size-kib", type=int, help=_("Minimum block size (KiB)"))
-@click.option("--max-block-size-kib", type=int, help=_("Maximum block size (KiB)"))
-@click.option("--enable-http-trackers", is_flag=True, help=_("Enable HTTP trackers"))
-@click.option("--disable-http-trackers", is_flag=True, help=_("Disable HTTP trackers"))
-@click.option("--enable-udp-trackers", is_flag=True, help=_("Enable UDP trackers"))
-@click.option("--disable-udp-trackers", is_flag=True, help=_("Disable UDP trackers"))
-@click.option(
- "--tracker-announce-interval",
- type=float,
- help=_("Tracker announce interval (s)"),
-)
-@click.option(
- "--tracker-scrape-interval",
- type=float,
- help=_("Tracker scrape interval (s)"),
-)
-@click.option("--pex-interval", type=float, help=_("PEX interval (s)"))
-@click.option("--endgame-duplicates", type=int, help=_("Endgame duplicate requests"))
-@click.option("--streaming-mode", is_flag=True, help=_("Enable streaming mode"))
-@click.option("--first-piece-priority", is_flag=True, help=_("Prioritize first piece"))
-@click.option("--last-piece-priority", is_flag=True, help=_("Prioritize last piece"))
-@click.option(
- "--optimistic-unchoke-interval",
- type=float,
- help=_("Optimistic unchoke interval (s)"),
-)
-@click.option("--unchoke-interval", type=float, help=_("Unchoke interval (s)"))
-@click.option("--metrics-interval", type=float, help=_("Metrics interval (s)"))
-@click.option(
- "--enable-v2", "enable_v2", is_flag=True, help=_("Enable Protocol v2 (BEP 52)")
-)
-@click.option(
- "--disable-v2", "disable_v2", is_flag=True, help=_("Disable Protocol v2 (BEP 52)")
-)
-@click.option(
- "--prefer-v2",
- "prefer_v2",
- is_flag=True,
- help=_("Prefer Protocol v2 when available"),
-)
-@click.option(
- "--v2-only", "v2_only", is_flag=True, help=_("Use Protocol v2 only (disable v1)")
-)
+@compose_click_options(*DOWNLOAD_MAGNET_SHARED_OPTIONS)
@click.pass_context
def magnet(
ctx,
@@ -1845,7 +1645,7 @@ def magnet(
console = Console()
try:
- # CRITICAL FIX: Use a single event loop for the entire operation
+ # Note: Use a single event loop for the entire operation
# This prevents "Event loop is closed" errors when IPCClient is created
# in one event loop and used in another
# Capture variables from outer scope for closure
@@ -1857,7 +1657,7 @@ def magnet(
async def _magnet_operation():
"""Handle magnet operation in a single event loop."""
- # CRITICAL FIX: Always check for daemon PID file FIRST before calling _get_executor()
+ # Note: Always check for daemon PID file FIRST before calling _get_executor()
# This prevents any possibility of creating a local session when daemon is running
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -1934,7 +1734,7 @@ async def _magnet_operation():
logger.debug(_("Error closing IPC client: %s"), e)
return
- # CRITICAL FIX: Double-check daemon PID file before creating local session
+ # Note: Double-check daemon PID file before creating local session
# This is a safety check - if we reach here, PID file should NOT exist
# (because we checked it at the start and _get_executor() would have raised if it existed)
# But we check again as a defensive measure
@@ -1963,7 +1763,7 @@ async def _magnet_operation():
)
# No daemon running - create local session and executor
- # CRITICAL FIX: Use ExecutorManager for consistency, even for local sessions
+ # Note: Use ExecutorManager for consistency, even for local sessions
from ccbt.executor.manager import ExecutorManager
# Load configuration
@@ -1980,12 +1780,12 @@ async def _magnet_operation():
# Create session (only when daemon is NOT running)
session = AsyncSessionManager(".")
- # CRITICAL FIX: Start session immediately to initialize NAT manager, TCP server, and port bindings
+ # Note: Start session immediately to initialize NAT manager, TCP server, and port bindings
# This ensures components use configured ports instead of random ports
# NOTE: This only runs when daemon is confirmed NOT running - no port conflicts possible
await session.start()
- # CRITICAL FIX: Use ExecutorManager to ensure consistent executor creation
+ # Note: Use ExecutorManager to ensure consistent executor creation
# This prevents duplicate executors and ensures proper session reference management
executor_manager = ExecutorManager.get_instance()
executor = executor_manager.get_executor(session_manager=session)
@@ -2138,14 +1938,14 @@ async def _magnet_operation():
@cli.command()
@click.option("--port", "-p", type=int, default=9090, help=_("Port for web interface"))
-@click.option("--host", "-h", default="localhost", help=_("Host for web interface"))
+@click.option("--host", "-H", default="localhost", help=_("Host for web interface"))
@click.pass_context
def web(ctx, port, host):
"""Start web interface."""
console = Console()
try:
- # CRITICAL FIX: Check for daemon PID file BEFORE creating local session
+ # Note: Check for daemon PID file BEFORE creating local session
# If PID file exists, we MUST prevent local session to avoid port conflicts
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -2190,7 +1990,7 @@ def interactive(ctx):
if executor is None:
# No daemon running - create local session and executor
- # CRITICAL FIX: Use ExecutorManager for consistency
+ # Note: Use ExecutorManager for consistency
from ccbt.executor.manager import ExecutorManager
session = AsyncSessionManager(".")
@@ -2312,27 +2112,19 @@ async def _get_status_async() -> None:
@cli.command()
-@click.pass_context
-def config(ctx):
- """Manage configuration."""
- console = Console()
-
- try:
- # Load configuration
- config_manager = ConfigManager(ctx.obj["config"])
- config = config_manager.config
-
- # Show configuration
- show_config(config, console)
-
- except Exception as e:
- console.print(_("[red]Error: {error}[/red]").format(error=e))
- raise click.ClickException(str(e)) from e
-
-
-@cli.command()
-@click.option("--set", "locale_code", help=_("Set locale (e.g., 'en', 'es', 'fr')"))
-@click.option("--list", "list_locales", is_flag=True, help=_("List available locales"))
+@click.option(
+ "--set",
+ "-s",
+ "locale_code",
+ help=_("Set locale (e.g., 'en', 'es', 'fr')"),
+)
+@click.option(
+ "--list",
+ "-L",
+ "list_locales",
+ is_flag=True,
+ help=_("List available locales"),
+)
@click.pass_context
def language(ctx, locale_code: Optional[str], list_locales: bool) -> None:
"""Manage language/locale settings."""
@@ -2393,7 +2185,7 @@ def debug(ctx):
console = Console()
try:
- # CRITICAL FIX: Check for daemon PID file BEFORE creating local session
+ # Note: Check for daemon PID file BEFORE creating local session
# If PID file exists, we MUST prevent local session to avoid port conflicts
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -2524,6 +2316,7 @@ def list_checkpoints(ctx, _checkpoint_format):
)
@click.option(
"--dry-run",
+ "-n",
is_flag=True,
help=_("Show what would be deleted without actually deleting"),
)
@@ -2674,12 +2467,14 @@ def verify_checkpoint_cmd(ctx, info_hash):
@click.argument("info_hash")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["json", "binary"]),
default="json",
)
@click.option(
"--output",
+ "-o",
"output_path",
type=click.Path(),
required=True,
@@ -2718,6 +2513,7 @@ def export_checkpoint_cmd(ctx, info_hash, format_, output_path):
@click.argument("info_hash")
@click.option(
"--destination",
+ "-d",
"destination",
type=click.Path(),
required=True,
@@ -2725,11 +2521,17 @@ def export_checkpoint_cmd(ctx, info_hash, format_, output_path):
)
@click.option(
"--compress",
+ "-c",
is_flag=True,
default=True,
help=_("Compress backup (default: yes)"),
)
-@click.option("--encrypt", is_flag=True, help=_("Encrypt backup with generated key"))
+@click.option(
+ "--encrypt",
+ "-e",
+ is_flag=True,
+ help=_("Encrypt backup with generated key"),
+)
@click.pass_context
def backup_checkpoint_cmd(ctx, info_hash, destination, compress, encrypt):
"""Backup a checkpoint to a destination path."""
@@ -2768,6 +2570,7 @@ def backup_checkpoint_cmd(ctx, info_hash, destination, compress, encrypt):
@click.argument("backup_file", type=click.Path(exists=True))
@click.option(
"--info-hash",
+ "-i",
"info_hash",
type=str,
default=None,
@@ -2810,8 +2613,16 @@ def restore_checkpoint_cmd(ctx, backup_file, info_hash):
@checkpoints.command("migrate")
@click.argument("info_hash")
-@click.option("--from-format", type=click.Choice(["json", "binary"]))
-@click.option("--to-format", type=click.Choice(["json", "binary", "both"]))
+@click.option(
+ "--from-format",
+ "-F",
+ type=click.Choice(["json", "binary"]),
+)
+@click.option(
+ "--to-format",
+ "-T",
+ type=click.Choice(["json", "binary", "both"]),
+)
@click.pass_context
def migrate_checkpoint_cmd(ctx, info_hash, from_format, to_format):
"""Migrate a checkpoint between formats."""
@@ -2846,12 +2657,14 @@ def migrate_checkpoint_cmd(ctx, info_hash, from_format, to_format):
@checkpoints.command("reload")
@click.argument("info_hash")
@click.option(
- "--peers/--no-peers",
+ "-P/--peers/--no-peers",
+ "peers",
default=True,
help=_("Reconnect to peers from checkpoint"),
)
@click.option(
- "--trackers/--no-trackers",
+ "-K/--trackers/--no-trackers",
+ "trackers",
default=True,
help=_("Refresh tracker state from checkpoint"),
)
@@ -2941,12 +2754,14 @@ async def _reload_via_daemon() -> None:
@checkpoints.command("refresh")
@click.argument("info_hash")
@click.option(
- "--peers/--no-peers",
+ "-P/--peers/--no-peers",
+ "peers",
default=True,
help=_("Reconnect to peers from checkpoint"),
)
@click.option(
- "--trackers/--no-trackers",
+ "-K/--trackers/--no-trackers",
+ "trackers",
default=True,
help=_("Refresh tracker state from checkpoint"),
)
@@ -3106,6 +2921,7 @@ async def _save_resume() -> None:
@click.argument("info_hash")
@click.option(
"--verify-pieces",
+ "-V",
type=int,
default=0,
help=_("Number of pieces to verify for integrity (0 = disable)"),
@@ -3246,7 +3062,7 @@ def resume(ctx, info_hash, _output_dir, interactive):
console = Console()
try:
- # CRITICAL FIX: Check for daemon PID file BEFORE creating local session
+ # Note: Check for daemon PID file BEFORE creating local session
# If PID file exists, we MUST prevent local session to avoid port conflicts
daemon_manager = DaemonManager()
pid_file_exists = daemon_manager.pid_file.exists()
@@ -3587,7 +3403,6 @@ async def start_debug_mode(_session: AsyncSessionManager, console: Console) -> N
# Register external command groups at module level so they appear in --help
cli.add_command(config_group)
-cli.add_command(config_extended)
cli.add_command(daemon_group)
cli.add_command(torrent_group)
cli.add_command(torrent_control_group)
@@ -3599,6 +3414,7 @@ async def start_debug_mode(_session: AsyncSessionManager, console: Console) -> N
cli.add_command(files_group)
cli.add_command(nat_group)
cli.add_command(ssl_group)
+cli.add_command(auth_group)
cli.add_command(proxy_group)
cli.add_command(scrape_group)
cli.add_command(resume_cmd)
diff --git a/ccbt/cli/monitoring_commands.py b/ccbt/cli/monitoring_commands.py
index 1bdccfd3..e257f40b 100644
--- a/ccbt/cli/monitoring_commands.py
+++ b/ccbt/cli/monitoring_commands.py
@@ -21,27 +21,27 @@
@click.command("dashboard")
-@click.option("--refresh", type=float, default=1.0, help="Refresh interval (s)")
+@click.option(
+ "--refresh",
+ "-r",
+ type=float,
+ default=1.0,
+ help="Refresh interval (s)",
+)
@click.option(
"--rules",
+ "-f",
type=click.Path(),
default=None,
help="Path to alert rules JSON to load on start",
)
-@click.option(
- "--no-daemon",
- is_flag=True,
- help="[DEPRECATED] Dashboard requires daemon; option is ignored",
-)
@click.option(
"--no-splash",
"-a",
is_flag=True,
help="Disable splash screen (useful for debugging)",
)
-def dashboard(
- refresh: float, rules: Optional[str], no_daemon: bool, no_splash: bool
-) -> None:
+def dashboard(refresh: float, rules: Optional[str], no_splash: bool) -> None:
"""Start terminal monitoring dashboard (Textual)."""
console = Console()
@@ -63,25 +63,14 @@ def dashboard(
# Start splash screen if enabled (only for daemon mode)
splash_manager = None
- if not no_daemon:
- splash_manager, _splash_thread = _show_startup_splash(
- no_splash=no_splash,
- verbosity_count=verbosity_count,
- console=console,
- )
-
+ splash_manager, _splash_thread = _show_startup_splash(
+ no_splash=no_splash,
+ verbosity_count=verbosity_count,
+ console=console,
+ )
session: Optional[Any] = (
None # Optional[AsyncSessionManager | DaemonInterfaceAdapter]
)
-
- if no_daemon:
- console.print(
- _(
- "[red]Dashboard requires daemon mode. "
- "The --no-daemon option is deprecated and not supported.[/red]"
- )
- )
- raise click.ClickException(DAEMON_STARTUP_FAILED_MSG)
# ALWAYS use daemon - try to ensure it's running
try:
success, ipc_client = asyncio.run(
@@ -101,8 +90,7 @@ def dashboard(
" 1. Daemon logs for startup errors\n"
" 2. Port conflicts (check if port is already in use)\n"
" 3. Permissions (ensure you have permission to start daemon)\n\n"
- "[cyan]To start daemon manually: 'btbt daemon start'[/cyan]\n"
- "[cyan]To use local session (not recommended): 'btbt dashboard --no-daemon'[/cyan]"
+ "[cyan]To start daemon manually: 'btbt daemon start'[/cyan]"
)
)
raise click.ClickException(DAEMON_STARTUP_FAILED_MSG)
@@ -145,20 +133,20 @@ def dashboard(
# Clear splash on interrupt
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.clear_progress_messages()
+ splash_manager.stop_splash()
raise
except Exception as e: # pragma: no cover - CLI error handler, hard to trigger reliably in unit tests
# Clear splash on error
if splash_manager:
with contextlib.suppress(Exception):
- splash_manager.clear_progress_messages()
+ splash_manager.stop_splash()
console.print(_("[red]Dashboard error: {e}[/red]").format(e=e))
raise
finally:
# Ensure splash is cleared on exit
if splash_manager:
try:
- splash_manager.clear_progress_messages()
+ splash_manager.stop_splash()
# Restore log level if it was suppressed
import logging
@@ -171,44 +159,52 @@ def dashboard(
@click.command("alerts")
-@click.option("--list", "list_", is_flag=True, help="List alert rules")
-@click.option("--list-active", is_flag=True, help="List active alerts")
-@click.option("--add", "add_rule", is_flag=True, help="Add an alert rule")
-@click.option("--remove", "remove_rule", is_flag=True, help="Remove an alert rule")
-@click.option("--clear-active", is_flag=True, help="Resolve all active alerts")
+@click.option("--list", "-L", "list_", is_flag=True, help="List alert rules")
+@click.option("--list-active", "-I", is_flag=True, help="List active alerts")
+@click.option("--add", "-a", "add_rule", is_flag=True, help="Add an alert rule")
+@click.option(
+ "--remove", "-R", "remove_rule", is_flag=True, help="Remove an alert rule"
+)
+@click.option("--clear-active", "-C", is_flag=True, help="Resolve all active alerts")
@click.option(
"--test",
+ "-t",
"test_rule",
is_flag=True,
help="Test a rule by evaluating a value",
)
@click.option(
"--load",
+ "-l",
type=click.Path(),
default=None,
help="Load alert rules from JSON file",
)
@click.option(
"--save",
+ "-s",
type=click.Path(),
default=None,
help="Save alert rules to JSON file",
)
-@click.option("--name", type=str, default=None, help="Rule name")
-@click.option("--metric", type=str, default=None, help="Metric name for rule")
+@click.option("--name", "-n", type=str, default=None, help="Rule name")
+@click.option("--metric", "-m", type=str, default=None, help="Metric name for rule")
@click.option(
"--condition",
+ "-c",
type=str,
default=None,
help="Condition expression, e.g., 'value > 80'",
)
@click.option(
"--severity",
+ "-e",
type=click.Choice(["info", "warning", "error", "critical"]),
default="warning",
)
@click.option(
"--value",
+ "-V",
type=str,
default=None,
help="Value to evaluate when using --test",
@@ -379,6 +375,7 @@ def alerts(
@click.command("metrics")
@click.option(
"--format",
+ "-f",
"format_",
type=click.Choice(["json", "prometheus"]),
default="json",
@@ -386,29 +383,34 @@ def alerts(
)
@click.option(
"--output",
+ "-o",
type=click.Path(),
default=None,
help="Output file (defaults to stdout)",
)
@click.option(
"--duration",
+ "-d",
type=float,
default=0.0,
help="Collect for N seconds (0 = once)",
)
@click.option(
"--interval",
+ "-i",
type=float,
default=None,
help="Collection interval seconds (defaults to config)",
)
@click.option(
"--include-system",
+ "-s",
is_flag=True,
help="Include system metrics snapshot in JSON output",
)
@click.option(
"--include-performance",
+ "-p",
is_flag=True,
help="Include performance metrics snapshot in JSON output",
)
diff --git a/ccbt/cli/nat_commands.py b/ccbt/cli/nat_commands.py
index 02e69d60..991deab3 100644
--- a/ccbt/cli/nat_commands.py
+++ b/ccbt/cli/nat_commands.py
@@ -190,15 +190,20 @@ async def _discover() -> None:
@nat.command("map")
-@click.option("--port", type=int, required=True, help="Port to map")
+@click.option("-p", "--port", type=int, required=True, help="Port to map")
@click.option(
+ "-P",
"--protocol",
type=click.Choice(["tcp", "udp"]),
default="tcp",
help="Protocol (tcp or udp)",
)
@click.option(
- "--external-port", type=int, default=0, help="External port (0 for automatic)"
+ "-e",
+ "--external-port",
+ type=int,
+ default=0,
+ help="External port (0 for automatic)",
)
@click.pass_context
def nat_map(_ctx, port: int, protocol: str, external_port: int) -> None:
@@ -271,8 +276,9 @@ async def _map_port() -> None:
@nat.command("unmap")
-@click.option("--port", type=int, required=True, help="External port to unmap")
+@click.option("-p", "--port", type=int, required=True, help="External port to unmap")
@click.option(
+ "-P",
"--protocol",
type=click.Choice(["tcp", "udp"]),
default="tcp",
diff --git a/ccbt/cli/overrides.py b/ccbt/cli/overrides.py
index f21241ec..bc977899 100644
--- a/ccbt/cli/overrides.py
+++ b/ccbt/cli/overrides.py
@@ -7,12 +7,17 @@
from __future__ import annotations
import contextlib
+import logging
from pathlib import Path
from typing import TYPE_CHECKING, Any
+from ccbt.cli.ssl_posture import is_strict_ssl_posture
+
if TYPE_CHECKING:
from ccbt.config.config import Config, ConfigManager
+_logger = logging.getLogger(__name__)
+
def apply_cli_overrides(cfg_mgr: ConfigManager, options: dict[str, Any]) -> None:
"""Apply all CLI overrides to configuration."""
@@ -115,7 +120,7 @@ def _apply_network_overrides(cfg: Config, options: dict[str, Any]) -> None:
if options.get("disable_utp"):
cfg.network.enable_utp = False
if options.get("enable_encryption"):
- cfg.network.enable_encryption = True
+ cfg.security.enable_encryption = True
if options.get("enable_webtorrent"):
cfg.network.webtorrent.enable_webtorrent = True
if options.get("disable_webtorrent"):
@@ -130,7 +135,7 @@ def _apply_network_overrides(cfg: Config, options: dict[str, Any]) -> None:
servers = [s.strip() for s in options["webtorrent_stun_servers"].split(",")]
cfg.network.webtorrent.webtorrent_stun_servers = servers
if options.get("disable_encryption"):
- cfg.network.enable_encryption = False
+ cfg.security.enable_encryption = False
if options.get("tcp_nodelay"):
cfg.network.tcp_nodelay = True
if options.get("no_tcp_nodelay"):
@@ -225,6 +230,22 @@ def _apply_strategy_overrides(cfg: Config, options: dict[str, Any]) -> None:
) # type: ignore[attr-defined]
if options.get("unchoke_interval") is not None:
cfg.network.unchoke_interval = float(options["unchoke_interval"]) # type: ignore[attr-defined]
+ if options.get("peer_choked_hard_timeout_seconds") is not None:
+ cfg.network.peer_choked_hard_timeout_seconds = float(
+ options["peer_choked_hard_timeout_seconds"],
+ )
+ if options.get("peer_choked_anchor_timeout_seconds") is not None:
+ cfg.network.peer_choked_anchor_timeout_seconds = float(
+ options["peer_choked_anchor_timeout_seconds"],
+ )
+ if options.get("peer_choked_solo_grace_seconds") is not None:
+ cfg.network.peer_choked_solo_grace_seconds = float(
+ options["peer_choked_solo_grace_seconds"],
+ )
+ if options.get("peer_choked_solo_grace_zero_bytes_cap_seconds") is not None:
+ cfg.network.peer_choked_solo_grace_zero_bytes_cap_seconds = float(
+ options["peer_choked_solo_grace_zero_bytes_cap_seconds"],
+ )
def _apply_disk_overrides(cfg: Config, options: dict[str, Any]) -> None:
@@ -458,6 +479,14 @@ def _apply_ssl_overrides(cfg: Config, options: dict[str, Any]) -> None:
cfg.security.ssl.ssl_client_key = str(key_path)
if options.get("no_ssl_verify"):
cfg.security.ssl.ssl_verify_certificates = False
+ _logger.warning(
+ "SSL certificate verification disabled (--no-ssl-verify). "
+ "HTTPS tracker connections will not validate server certificates.",
+ )
+ if is_strict_ssl_posture(cfg.security.ssl):
+ _logger.warning(
+ "Strict SSL posture requested while verification is disabled."
+ )
if options.get("ssl_protocol_version"):
cfg.security.ssl.ssl_protocol_version = options["ssl_protocol_version"]
diff --git a/ccbt/cli/proxy_commands.py b/ccbt/cli/proxy_commands.py
index 63e1980a..9bffc49e 100644
--- a/ccbt/cli/proxy_commands.py
+++ b/ccbt/cli/proxy_commands.py
@@ -57,37 +57,39 @@ def proxy() -> None:
@proxy.command("set")
-@click.option("--host", required=True, help="Proxy server hostname or IP")
-@click.option("--port", type=int, required=True, help="Proxy server port")
+@click.option("--host", "-H", required=True, help="Proxy server hostname or IP")
+@click.option("--port", "-p", type=int, required=True, help="Proxy server port")
@click.option(
"--type",
+ "-T",
"proxy_type",
type=click.Choice(["http", "socks4", "socks5"]),
default="http",
help="Proxy type",
)
-@click.option("--user", "username", help="Proxy username for authentication")
+@click.option("--user", "-U", "username", help="Proxy username for authentication")
@click.option("--pass", "password", help="Proxy password for authentication")
@click.option(
- "--for-trackers/--no-for-trackers",
+ "-A/--for-trackers/--no-for-trackers",
"for_trackers",
default=True,
help="Use proxy for tracker requests",
)
@click.option(
- "--for-peers/--no-for-peers",
+ "-R/--for-peers/--no-for-peers",
"for_peers",
default=False,
help="Use proxy for peer connections",
)
@click.option(
- "--for-webseeds/--no-for-webseeds",
+ "-W/--for-webseeds/--no-for-webseeds",
"for_webseeds",
default=True,
help="Use proxy for WebSeed requests",
)
@click.option(
"--bypass-list",
+ "-B",
help="Comma-separated list of hosts/IPs to bypass proxy",
)
@click.pass_context
diff --git a/ccbt/cli/queue_commands.py b/ccbt/cli/queue_commands.py
index 847958f1..533e3f7a 100644
--- a/ccbt/cli/queue_commands.py
+++ b/ccbt/cli/queue_commands.py
@@ -107,6 +107,7 @@ async def _list_queue() -> None:
@click.argument("info_hash")
@click.option(
"--priority",
+ "-p",
type=click.Choice(["maximum", "high", "normal", "low", "paused"]),
default="normal",
help=_("Priority level"),
diff --git a/ccbt/cli/resume.py b/ccbt/cli/resume.py
index b833995d..1351e6ad 100644
--- a/ccbt/cli/resume.py
+++ b/ccbt/cli/resume.py
@@ -25,7 +25,7 @@ async def resume_download(
console: Console,
) -> None:
"""Resume a download from a checkpoint."""
- # CRITICAL FIX: Create session safely if not provided
+ # Note: Create session safely if not provided
if session is None:
from ccbt.cli.main import _ensure_local_session_safe
diff --git a/ccbt/cli/ssl_posture.py b/ccbt/cli/ssl_posture.py
new file mode 100644
index 00000000..fbdea1af
--- /dev/null
+++ b/ccbt/cli/ssl_posture.py
@@ -0,0 +1,16 @@
+"""Shared helpers for security posture checks in CLI surfaces."""
+
+from __future__ import annotations
+
+from typing import Any
+
+
+def is_strict_ssl_posture(ssl_cfg: Any) -> bool:
+ """Return True when strict SSL posture is active but verification is disabled."""
+ strict_transport = bool(getattr(ssl_cfg, "enable_ssl_trackers", False)) or (
+ bool(getattr(ssl_cfg, "enable_ssl_peers", False))
+ and not bool(getattr(ssl_cfg, "ssl_allow_insecure_peers", False))
+ )
+ return strict_transport and not bool(
+ getattr(ssl_cfg, "ssl_verify_certificates", True)
+ )
diff --git a/ccbt/cli/status.py b/ccbt/cli/status.py
index 3f3b676e..7f53604b 100644
--- a/ccbt/cli/status.py
+++ b/ccbt/cli/status.py
@@ -195,9 +195,14 @@ async def show_status(session: AsyncSessionManager, console: Console) -> None:
if session.config.network.enable_utp:
try:
- from ccbt.transport.utp_socket import UTPSocketManager
-
- socket_manager = await UTPSocketManager.get_instance()
+ socket_manager = getattr(session, "utp_socket_manager", None) or getattr(
+ getattr(session, "session_manager", None), "utp_socket_manager", None
+ )
+ if socket_manager is None:
+ _utp_socket_manager_not_initialized = (
+ "uTP socket manager not initialized"
+ )
+ raise RuntimeError(_utp_socket_manager_not_initialized)
stats = socket_manager.get_statistics()
utp_status = _("Enabled")
utp_details = _(
diff --git a/ccbt/cli/tonic_commands.py b/ccbt/cli/tonic_commands.py
index f20c8e73..727b74f8 100644
--- a/ccbt/cli/tonic_commands.py
+++ b/ccbt/cli/tonic_commands.py
@@ -116,30 +116,36 @@ def tonic() -> None:
)
@click.option(
"--sync-mode",
+ "-s",
type=click.Choice(["designated", "best_effort", "broadcast", "consensus"]),
default="best_effort",
help="Synchronization mode",
)
@click.option(
"--source-peers",
+ "-p",
help="Comma-separated list of designated source peer IDs",
)
@click.option(
"--allowlist",
+ "-a",
"allowlist_path",
type=click.Path(),
help="Path to allowlist file",
)
@click.option(
"--git-ref",
+ "-g",
help="Git commit hash/ref to track",
)
@click.option(
"--announce",
+ "-n",
help="Primary tracker announce URL",
)
@click.option(
"--generate-link",
+ "-l",
is_flag=True,
help="Also generate tonic?: link",
)
@@ -175,11 +181,13 @@ def tonic_create(
)
@click.option(
"--tonic-file",
+ "-t",
type=click.Path(exists=True),
help="Path to .tonic file (if not provided, will generate)",
)
@click.option(
"--sync-mode",
+ "-s",
type=click.Choice(["designated", "best_effort", "broadcast", "consensus"]),
help="Synchronization mode (overrides .tonic file)",
)
@@ -257,6 +265,7 @@ def tonic_link(
)
@click.option(
"--check-interval",
+ "-c",
type=float,
default=5.0,
help="Check interval in seconds",
@@ -384,18 +393,21 @@ async def _fetch_status() -> Any:
)
@click.option(
"--sync-mode",
+ "-s",
type=click.Choice(["designated", "best_effort", "broadcast", "consensus"]),
default="best_effort",
help="Synchronization mode",
)
@click.option(
"--check-interval",
+ "-i",
type=float,
default=None,
help="Folder check interval in seconds",
)
@click.option(
"--allowlist",
+ "-a",
"_allowlist_path",
type=click.Path(),
help="Path to allowlist file",
@@ -474,10 +486,12 @@ def tonic_allowlist() -> None:
@click.argument("peer_id", type=str)
@click.option(
"--public-key",
+ "-P",
help="Ed25519 public key (hex format, 64 chars)",
)
@click.option(
"--alias",
+ "-A",
help="Human-readable alias for this peer",
)
@click.pass_context
@@ -626,6 +640,7 @@ def tonic_mode() -> None:
)
@click.option(
"--source-peers",
+ "-p",
help="Comma-separated list of source peer IDs (for designated mode)",
)
@click.pass_context
diff --git a/ccbt/cli/tonic_generator.py b/ccbt/cli/tonic_generator.py
index b017801b..535418cd 100644
--- a/ccbt/cli/tonic_generator.py
+++ b/ccbt/cli/tonic_generator.py
@@ -215,30 +215,36 @@ async def generate_tonic_from_folder(
)
@click.option(
"--sync-mode",
+ "-s",
type=click.Choice(["designated", "best_effort", "broadcast", "consensus"]),
default="best_effort",
help="Synchronization mode",
)
@click.option(
"--source-peers",
+ "-p",
help="Comma-separated list of designated source peer IDs",
)
@click.option(
"--allowlist",
+ "-a",
"allowlist_path",
type=click.Path(),
help="Path to allowlist file",
)
@click.option(
"--git-ref",
+ "-g",
help="Git commit hash/ref to track (default: current HEAD)",
)
@click.option(
"--announce",
+ "-n",
help="Primary tracker announce URL",
)
@click.option(
"--generate-link",
+ "-l",
is_flag=True,
help="Also generate tonic?: link",
)
diff --git a/ccbt/cli/torrent_commands.py b/ccbt/cli/torrent_commands.py
index 2c4306f8..2ce05a15 100644
--- a/ccbt/cli/torrent_commands.py
+++ b/ccbt/cli/torrent_commands.py
@@ -354,7 +354,8 @@ def dht() -> None:
@dht.command("aggressive")
@click.argument("info_hash")
@click.option(
- "--enable/--disable",
+ "-e/--enable/--disable",
+ "enable",
default=True,
help="Enable or disable aggressive mode (default: enable)",
)
diff --git a/ccbt/cli/torrent_config_commands.py b/ccbt/cli/torrent_config_commands.py
index 21aedba9..9b7dc175 100644
--- a/ccbt/cli/torrent_config_commands.py
+++ b/ccbt/cli/torrent_config_commands.py
@@ -196,6 +196,7 @@ async def _set_torrent_option(
@click.argument("value")
@click.option(
"--save-checkpoint",
+ "-S",
is_flag=True,
help=_("Save checkpoint immediately after setting option"),
)
@@ -541,11 +542,13 @@ async def _reset_torrent_options(
@click.argument("info_hash")
@click.option(
"--key",
+ "-k",
type=str,
help=_("Reset specific key only (otherwise resets all options)"),
)
@click.option(
"--save-checkpoint",
+ "-S",
is_flag=True,
help=_("Save checkpoint after reset"),
)
diff --git a/ccbt/cli/verbosity.py b/ccbt/cli/verbosity.py
index 20dfdfa6..0c3250b4 100644
--- a/ccbt/cli/verbosity.py
+++ b/ccbt/cli/verbosity.py
@@ -9,7 +9,7 @@
from enum import IntEnum
from typing import Any, ClassVar, Optional
-from ccbt.utils.logging_config import get_logger
+from ccbt.utils.logging_config import TRACE_LOG_LEVEL, get_logger
logger = get_logger(__name__)
@@ -41,7 +41,7 @@ class VerbosityManager:
VerbosityLevel.NORMAL: logging.INFO,
VerbosityLevel.VERBOSE: logging.INFO,
VerbosityLevel.DEBUG: logging.DEBUG,
- VerbosityLevel.TRACE: logging.DEBUG, # TRACE uses DEBUG with stack traces
+ VerbosityLevel.TRACE: TRACE_LOG_LEVEL, # TRACE uses dedicated TRACE level
}
def __init__(self, verbosity_count: int = 0):
@@ -97,6 +97,15 @@ def get_logging_level(self) -> int:
Returns:
Logging level constant
+ """
+ return self.logging_level_for_verbosity()
+
+ def logging_level_for_verbosity(self) -> int:
+ """Return the effective logging level for this verbosity.
+
+ Returns:
+ The logging level constant.
+
"""
return self.logging_level
@@ -128,6 +137,40 @@ def is_trace(self) -> bool:
return self.level == VerbosityLevel.TRACE
+def effective_observability_log_level(base_log_level: Any, verbosity_count: int) -> Any:
+ """Resolve effective log level from observability baseline and ``-v`` count.
+
+ Matches the semantics of the root ``cli()`` Click group callback in
+ ``ccbt.cli.main``.
+ """
+ from ccbt.models import LogLevel
+
+ vm = VerbosityManager.from_count(verbosity_count)
+ effective: Any = base_log_level
+ if vm.is_trace():
+ effective = vm.logging_level_for_verbosity()
+ elif vm.is_debug():
+ effective = LogLevel.DEBUG
+ elif vm.is_verbose():
+ effective = LogLevel.INFO
+ return effective
+
+
+def apply_cli_verbosity_to_observability(
+ observability: Any,
+ verbosity_count: int,
+) -> None:
+ """Apply verbosity to logging and persist for later :class:`ConfigManager` inits."""
+ from ccbt.utils.logging_config import (
+ set_cli_session_log_level_override,
+ setup_logging,
+ )
+
+ eff = effective_observability_log_level(observability.log_level, verbosity_count)
+ set_cli_session_log_level_override(eff)
+ setup_logging(observability, effective_log_level=eff)
+
+
def get_verbosity_from_ctx(ctx: Optional[dict[str, Any]]) -> VerbosityManager:
"""Get verbosity manager from Click context.
diff --git a/ccbt/cli/xet_commands.py b/ccbt/cli/xet_commands.py
index 8c88374b..b1688a23 100644
--- a/ccbt/cli/xet_commands.py
+++ b/ccbt/cli/xet_commands.py
@@ -22,7 +22,7 @@ def xet() -> None:
@xet.command("enable")
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.pass_context
def xet_enable(_ctx, config_file: Optional[str]) -> None:
"""Enable Xet protocol in configuration."""
@@ -50,7 +50,7 @@ async def _enable() -> Any:
@xet.command("disable")
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.pass_context
def xet_disable(_ctx, config_file: Optional[str]) -> None:
"""Disable Xet protocol in configuration."""
@@ -78,7 +78,7 @@ async def _disable() -> Any:
@xet.command("status")
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.pass_context
def xet_status(_ctx, config_file: Optional[str]) -> None:
"""Show Xet protocol status and configuration."""
@@ -156,8 +156,14 @@ async def _load_status() -> tuple[Any, Any]:
@xet.command("stats")
-@click.option("--config", "config_file", type=click.Path(), default=None)
-@click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
+@click.option(
+ "--json",
+ "-j",
+ "json_output",
+ is_flag=True,
+ help="Output in JSON format",
+)
@click.pass_context
def xet_stats(_ctx, config_file: Optional[str], json_output: bool) -> None:
"""Show Xet deduplication cache statistics."""
@@ -202,9 +208,21 @@ async def _show_stats() -> None:
@xet.command("cache-info")
-@click.option("--config", "config_file", type=click.Path(), default=None)
-@click.option("--json", "json_output", is_flag=True, help="Output in JSON format")
-@click.option("--limit", type=int, default=10, help="Limit number of chunks to show")
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
+@click.option(
+ "--json",
+ "-j",
+ "json_output",
+ is_flag=True,
+ help="Output in JSON format",
+)
+@click.option(
+ "--limit",
+ "-l",
+ type=int,
+ default=10,
+ help="Limit number of chunks to show",
+)
@click.pass_context
def xet_cache_info(
_ctx, config_file: Optional[str], json_output: bool, limit: int
@@ -277,14 +295,19 @@ async def _show_cache_info() -> None:
@xet.command("cleanup")
-@click.option("--config", "config_file", type=click.Path(), default=None)
+@click.option("--config", "-c", "config_file", type=click.Path(), default=None)
@click.option(
"--dry-run",
+ "-n",
is_flag=True,
help="Show what would be cleaned without actually cleaning",
)
@click.option(
- "--max-age-days", type=int, default=30, help="Maximum age in days for unused chunks"
+ "--max-age-days",
+ "-g",
+ type=int,
+ default=30,
+ help="Maximum age in days for unused chunks",
)
@click.pass_context
def xet_cleanup(
diff --git a/ccbt/config/config.py b/ccbt/config/config.py
index 7dcd88d2..b30ebd96 100644
--- a/ccbt/config/config.py
+++ b/ccbt/config/config.py
@@ -1,9 +1,10 @@
"""Configuration management for ccBitTorrent.
-from __future__ import annotations
-
Provides centralized configuration with TOML support, validation, hot-reload,
-and hierarchical loading from defaults → config file → environment → CLI → per-torrent.
+and deterministic effective precedence for static loads:
+file (base) → optimization profile overlay → environment → platform (Windows) clamp
+→ validated :class:`~ccbt.models.Config`. CLI and per-torrent overrides apply at
+their respective layers after this.
"""
from __future__ import annotations
@@ -11,6 +12,7 @@
import asyncio
import base64
import logging
+import math
import os
import sys
from pathlib import Path
@@ -61,17 +63,24 @@ def _safe_get_plugins():
except ImportError:
Fernet = None # type: ignore[assignment, misc]
+from ccbt.config.config_cli_values import COMMA_SEPARATED_LIST_PATHS
from ccbt.models import (
Config,
DiscoveryConfig,
DiskConfig,
+ MaxPeersPerTorrentProvenance,
NetworkConfig,
ObservabilityConfig,
OptimizationProfile,
StrategyConfig,
)
from ccbt.utils.exceptions import ConfigurationError
-from ccbt.utils.logging_config import get_logger, setup_logging
+from ccbt.utils.logging_config import (
+ get_cli_session_log_level_override,
+ get_logger,
+ set_cli_session_log_level_override,
+ setup_logging,
+)
# Platform detection
IS_WINDOWS = sys.platform == "win32"
@@ -82,6 +91,196 @@ def _safe_get_plugins():
_config_manager: Optional[ConfigManager] = None
+def _optimization_profile_overlays() -> dict[
+ OptimizationProfile, dict[str, dict[str, Any]]
+]:
+ """Return optimization profile overlays merged during load (after file, before env)."""
+ return {
+ OptimizationProfile.BALANCED: {
+ "strategy": {
+ "piece_selection": "rarest_first",
+ "pipeline_capacity": 4,
+ "endgame_duplicates": 2,
+ },
+ "network": {
+ "max_peers_per_torrent": 50,
+ "max_global_peers": 200,
+ },
+ "discovery": {
+ "tracker_announce_interval": 60.0,
+ },
+ "optimization": {
+ "enable_adaptive_intervals": True,
+ "enable_performance_based_recycling": True,
+ "enable_bandwidth_aware_scheduling": True,
+ },
+ },
+ OptimizationProfile.SPEED: {
+ "strategy": {
+ "piece_selection": "bandwidth_weighted_rarest",
+ "pipeline_capacity": 8,
+ "endgame_duplicates": 3,
+ },
+ "network": {
+ "max_peers_per_torrent": 100,
+ "max_global_peers": 500,
+ },
+ "discovery": {
+ "tracker_announce_interval": 30.0,
+ },
+ "optimization": {
+ "enable_adaptive_intervals": True,
+ "enable_performance_based_recycling": True,
+ "speed_aggressive_peer_recycling": True,
+ "enable_bandwidth_aware_scheduling": True,
+ },
+ },
+ OptimizationProfile.EFFICIENCY: {
+ "strategy": {
+ "piece_selection": "adaptive_hybrid",
+ "pipeline_capacity": 6,
+ "endgame_duplicates": 2,
+ },
+ "network": {
+ "max_peers_per_torrent": 30,
+ "max_global_peers": 150,
+ },
+ "discovery": {
+ "tracker_announce_interval": 90.0,
+ },
+ "optimization": {
+ "enable_adaptive_intervals": True,
+ "enable_performance_based_recycling": True,
+ "efficiency_connection_limit_multiplier": 0.8,
+ "enable_bandwidth_aware_scheduling": True,
+ },
+ },
+ OptimizationProfile.LOW_RESOURCE: {
+ "strategy": {
+ "piece_selection": "rarest_first",
+ "pipeline_capacity": 2,
+ "endgame_duplicates": 1,
+ },
+ "network": {
+ "max_peers_per_torrent": 10,
+ "max_global_peers": 50,
+ },
+ "discovery": {
+ "tracker_announce_interval": 120.0,
+ },
+ "optimization": {
+ "enable_adaptive_intervals": False,
+ "enable_performance_based_recycling": False,
+ "low_resource_max_connections": 20,
+ "enable_bandwidth_aware_scheduling": False,
+ },
+ },
+ OptimizationProfile.CUSTOM: {},
+ }
+
+
+def resolve_effective_max_peers_per_torrent(
+ *,
+ network_cap: int,
+ per_torrent: Optional[int],
+) -> int:
+ """Return peer cap after global config; per-torrent option replaces when set (>= 0)."""
+ if per_torrent is not None and int(per_torrent) >= 0:
+ return int(per_torrent)
+ return int(network_cap)
+
+
+def _strip_inline_comment_suffix(text: str) -> str:
+ """Remove a trailing inline comment after whitespace+``#``.
+
+ TOML treats ``#`` inside double-quoted strings as literal. If a line like
+ ``max_global_peers = 10000 # cap`` is mistakenly pasted as a single
+ quoted string, the whole fragment becomes the value and Pydantic cannot
+ coerce it. This keeps the payload before the first whitespace-``#``.
+ """
+ s = text
+ i = 0
+ while True:
+ idx = s.find("#", i)
+ if idx == -1:
+ return s
+ if idx > 0 and s[idx - 1].isspace():
+ return s[: idx - 1].rstrip()
+ i = idx + 1
+
+
+def _strip_inline_comments_deep(obj: Any) -> Any:
+ """Apply :func:`_strip_inline_comment_suffix` to all strings in nested dict/list."""
+ if isinstance(obj, dict):
+ for key, val in list(obj.items()):
+ obj[key] = _strip_inline_comments_deep(val)
+ return obj
+ if isinstance(obj, list):
+ for i, item in enumerate(obj):
+ obj[i] = _strip_inline_comments_deep(item)
+ return obj
+ if isinstance(obj, str):
+ return _strip_inline_comment_suffix(obj)
+ return obj
+
+
+def _prune_empty_string_config_values(obj: Any) -> None:
+ r"""Drop dict keys whose value is empty or whitespace-only.
+
+ ``.env`` lines like ``VAR= # note`` and bare ``VAR=`` become ``""`` in
+ ``os.environ``; TOML can also set ``key = \"\"``. Removing the key lets
+ Pydantic use sub-model defaults (e.g. optional ports, ``daemon.ipc_port``).
+ """
+ if isinstance(obj, dict):
+ empty_keys = [
+ k for k, v in obj.items() if isinstance(v, str) and v.strip() == ""
+ ]
+ for k in empty_keys:
+ del obj[k]
+ for v in obj.values():
+ _prune_empty_string_config_values(v)
+ elif isinstance(obj, list):
+ for item in obj:
+ _prune_empty_string_config_values(item)
+
+
+def _try_coerce_network_int(value: Any) -> Optional[int]:
+ """Parse an int for Windows network clamp comparisons only.
+
+ Returns ``None`` for missing values, booleans, and non-numeric strings so we
+ skip clamping and let Pydantic report invalid ``network.*`` integers.
+ """
+ if value is None:
+ return None
+ if isinstance(value, bool):
+ return None
+ if isinstance(value, int):
+ return value
+ if isinstance(value, float):
+ if math.isnan(value):
+ return None
+ return int(value)
+ if isinstance(value, str):
+ text = _strip_inline_comment_suffix(value.strip())
+ if not text:
+ return None
+ try:
+ if any(c in text for c in ".eE"):
+ return int(float(text))
+ return int(text, 10)
+ except ValueError:
+ return None
+ return None
+
+
+def _snapshot_max_peers_per_torrent(config_data: dict[str, Any]) -> Optional[int]:
+ """Return coerced ``network.max_peers_per_torrent`` from raw load data, if present."""
+ net = config_data.get("network")
+ if not isinstance(net, dict):
+ return None
+ return _try_coerce_network_int(net.get("max_peers_per_torrent"))
+
+
class ConfigManager:
"""Manages configuration loading, validation, and hot-reload."""
@@ -96,12 +295,11 @@ def __init__(self, config_file: Optional[Union[str, Path]] = None):
self._hot_reload_task: Optional[asyncio.Task] = None
self._encryption_key: Optional[bytes] = None
self.config_file = self._find_config_file(config_file)
+ self.max_peers_per_torrent_provenance: Optional[
+ MaxPeersPerTorrentProvenance
+ ] = None
self.config = self._load_config()
- # Apply optimization profile if specified (after config is loaded)
- if self.config.optimization.profile != OptimizationProfile.CUSTOM:
- self.apply_profile()
-
self._setup_logging()
def _find_config_file(
@@ -125,91 +323,360 @@ def _find_config_file(
return None # pragma: no cover
- def _load_config(self) -> Config:
- """Load configuration from file and environment."""
- # Start with defaults
- config_data = {}
+ def _normalize_loaded_config_data(self, config_data: dict[str, Any]) -> None:
+ """Normalize file-derived config in place (comma-lists, proxy password)."""
+ _strip_inline_comments_deep(config_data)
+ security = config_data.get("security")
+ if isinstance(security, dict):
+
+ def _normalize_encryption_mode_alias(value: Any) -> str:
+ if value is None:
+ return "preferred"
+ normalized = str(value).strip().lower().replace("-", "_")
+ normalized = normalized.replace(" ", "_")
+ if normalized in {
+ "disabled",
+ "off",
+ "false",
+ "0",
+ "none",
+ "plaintext_only",
+ }:
+ return "disabled"
+ if normalized in {
+ "preferred",
+ "prefer",
+ "optional",
+ "enable",
+ "enabled",
+ "true",
+ "yes",
+ "on",
+ "1",
+ "allow_plaintext",
+ "prefer_encrypted",
+ "prefer_plaintext",
+ }:
+ # Legacy and human-friendly aliases are normalized to preferred.
+ return "preferred"
+ if normalized in {
+ "required",
+ "mandatory",
+ "force",
+ "require_encrypted",
+ }:
+ return "required"
+ return normalized
+
+ legacy_pref = security.pop("encryption_preference", None)
+ if legacy_pref is not None and security.get("encryption_mode") is None:
+ pref_key = (
+ str(legacy_pref).lower().strip().replace(" ", "_").replace("-", "_")
+ )
+ pref_to_mode = {
+ "allow_plaintext": "preferred",
+ "prefer_encrypted": "preferred",
+ "require_encrypted": "required",
+ "disabled": "disabled",
+ }
+ security["encryption_mode"] = pref_to_mode.get(pref_key, "preferred")
+ elif isinstance(security.get("encryption_mode"), str):
+ security["encryption_mode"] = _normalize_encryption_mode_alias(
+ security["encryption_mode"]
+ )
- # Load from TOML file if exists
- if self.config_file and self.config_file.exists():
- try:
- with open(self.config_file, encoding="utf-8") as f:
- toml_data = toml.load(f)
- config_data.update(toml_data)
+ if "encryption_mode" not in security and "enable_encryption" in security:
+ # No explicit mode means keep preferred by default.
+ security["encryption_mode"] = "preferred"
+ network = config_data.get("network")
+ if (
+ isinstance(network, dict)
+ and "enable_encryption" in network
+ and "enable_encryption" not in security
+ ):
+ security["enable_encryption"] = bool(network["enable_encryption"])
- # Parse list values from comma-separated strings
- if (
- "security" in config_data
- and "encryption_allowed_ciphers" in config_data.get("security", {})
- ):
- value = config_data["security"]["encryption_allowed_ciphers"]
- if isinstance(value, str) and "," in value:
- config_data["security"]["encryption_allowed_ciphers"] = [
- item.strip() for item in value.split(",") if item.strip()
- ]
-
- if "proxy" in config_data and "proxy_bypass_list" in config_data.get(
- "proxy", {}
- ):
- value = config_data["proxy"]["proxy_bypass_list"]
- if isinstance(value, str) and "," in value:
- config_data["proxy"]["proxy_bypass_list"] = [
- item.strip() for item in value.split(",") if item.strip()
- ]
-
- # Decrypt proxy password if encrypted
- if "proxy" in config_data and config_data["proxy"].get(
- "proxy_password"
- ):
- password = config_data["proxy"]["proxy_password"]
- if self._is_encrypted(password):
- try:
- decrypted = self._decrypt_proxy_password(password)
- config_data["proxy"]["proxy_password"] = decrypted
- except Exception as e:
- logging.warning("Failed to decrypt proxy password: %s", e)
- # Continue with encrypted value (will be re-encrypted on save)
- except Exception as e:
- logging.warning(
- "Failed to load config file %s: %s", self.config_file, e
- )
+ if (
+ "security" in config_data
+ and "encryption_allowed_ciphers" in config_data.get("security", {})
+ ):
+ value = config_data["security"]["encryption_allowed_ciphers"]
+ normalized: list[str] = []
+ if isinstance(value, str):
+ raw_items = value.split(",")
+ elif isinstance(value, list):
+ raw_items = []
+ for item in value:
+ if isinstance(item, str):
+ raw_items.extend(item.split(","))
+ else:
+ raw_items.append(str(item))
+ else:
+ raw_items = [str(value)]
- # Apply environment overrides
+ for item in raw_items:
+ token = _strip_inline_comment_suffix(str(item).strip())
+ if token:
+ normalized.append(token)
+
+ config_data["security"]["encryption_allowed_ciphers"] = normalized
+
+ if "proxy" in config_data and "proxy_bypass_list" in config_data.get(
+ "proxy", {}
+ ):
+ value = config_data["proxy"]["proxy_bypass_list"]
+ if isinstance(value, str) and "," in value:
+ config_data["proxy"]["proxy_bypass_list"] = [
+ item.strip() for item in value.split(",") if item.strip()
+ ]
+
+ if "proxy" in config_data and config_data["proxy"].get("proxy_password"):
+ password = config_data["proxy"]["proxy_password"]
+ if self._is_encrypted(password):
+ try:
+ decrypted = self._decrypt_proxy_password(password)
+ config_data["proxy"]["proxy_password"] = decrypted
+ except Exception as e:
+ logging.warning("Failed to decrypt proxy password: %s", e)
+
+ def _apply_env_windows_and_build_config(
+ self,
+ config_data: dict[str, Any],
+ *,
+ provenance_profile: Optional[OptimizationProfile] = None,
+ provenance_after_file_mpt: Optional[int] = None,
+ provenance_after_profile_mpt: Optional[int] = None,
+ ) -> Config:
+ """Merge env, apply Windows network caps, construct ``Config``, record MPT provenance.
+
+ Precedence before this step: file (base) → optimization profile overlay →
+ (this method) environment → Windows compatibility clamp → ``Config`` validation.
+ Per-torrent caps are applied later in session/peer setup, not here.
+ """
env_config = self._get_env_config()
config_data = self._merge_config(config_data, env_config)
+ _strip_inline_comments_deep(config_data)
+ _prune_empty_string_config_values(config_data)
- # CRITICAL FIX: Apply Windows-specific connection limits to prevent socket buffer exhaustion
- # Windows has stricter limits on socket buffers (WinError 10055)
+ mpt_after_env = _snapshot_max_peers_per_torrent(config_data)
+ env_ccbt_mpt_set = "CCBT_MAX_PEERS_PER_TORRENT" in os.environ
+
+ win_strict_effective = False
if IS_WINDOWS and "network" in config_data:
network_config = config_data.get("network", {})
- # Reduce connection limits on Windows to prevent socket buffer exhaustion
- if network_config.get("max_global_peers", 600) > 200:
- network_config["max_global_peers"] = 200
- logging.debug(
- "Reduced max_global_peers to 200 for Windows compatibility"
- )
- if network_config.get("connection_pool_max_connections", 400) > 150:
- network_config["connection_pool_max_connections"] = 150
- logging.debug(
- "Reduced connection_pool_max_connections to 150 for Windows compatibility"
- )
- if network_config.get("max_peers_per_torrent", 200) > 100:
- network_config["max_peers_per_torrent"] = 100
- logging.debug(
- "Reduced max_peers_per_torrent to 100 for Windows compatibility"
+ strict_raw = os.environ.get("CCBT_WINDOWS_NETWORK_COMPAT_STRICT", "true")
+ win_strict_effective = str(strict_raw).strip().lower() not in (
+ "0",
+ "false",
+ "no",
+ "off",
+ )
+ # Windows caps apply here during env merge. Additional network limit tweaks may
+ # run later via config_conditional (e.g. interface-count-based max_global_peers).
+ if win_strict_effective:
+ mgp_raw = network_config.get("max_global_peers", 600)
+ mgp = _try_coerce_network_int(mgp_raw)
+ if mgp is not None and mgp > 200:
+ network_config["max_global_peers"] = 200
+ logging.info(
+ "Clamped network.max_global_peers from %s to 200 on Windows compatibility path",
+ mgp_raw,
+ )
+ pool_raw = network_config.get("connection_pool_max_connections", 400)
+ pool = _try_coerce_network_int(pool_raw)
+ if pool is not None and pool > 150:
+ network_config["connection_pool_max_connections"] = 150
+ logging.info(
+ "Clamped network.connection_pool_max_connections from %s to 150 on Windows compatibility path",
+ pool_raw,
+ )
+ mpt_raw = network_config.get("max_peers_per_torrent", 200)
+ mpt = _try_coerce_network_int(mpt_raw)
+ if mpt is not None and mpt > 100:
+ network_config["max_peers_per_torrent"] = 100
+ logging.info(
+ "Clamped network.max_peers_per_torrent from %s to 100 on Windows compatibility path",
+ mpt_raw,
+ )
+ else:
+ logging.warning(
+ "Windows peer-limit clamps skipped (CCBT_WINDOWS_NETWORK_COMPAT_STRICT=%r). "
+ "Higher limits can be unstable on some Windows stacks.",
+ strict_raw,
)
config_data["network"] = network_config
- try:
- # Create Pydantic model with validation
- return Config(**config_data)
+ mpt_after_platform = _snapshot_max_peers_per_torrent(config_data)
+ win_clamp_mpt = (
+ IS_WINDOWS
+ and "network" in config_data
+ and win_strict_effective
+ and mpt_after_env is not None
+ and mpt_after_platform is not None
+ and mpt_after_env != mpt_after_platform
+ )
+
+ profile_value = (
+ provenance_profile.value
+ if provenance_profile is not None
+ else OptimizationProfile.BALANCED.value
+ )
- # Apply optimization profile if specified (after config is created)
- # We'll apply it in __init__ after self.config is set
+ try:
+ cfg = Config(**config_data)
except Exception as e:
+ self.max_peers_per_torrent_provenance = None
msg = f"Invalid configuration: {e}"
raise ConfigurationError(msg) from e
+ self.max_peers_per_torrent_provenance = MaxPeersPerTorrentProvenance(
+ optimization_profile=profile_value,
+ value_after_file=provenance_after_file_mpt,
+ value_after_profile=provenance_after_profile_mpt,
+ value_after_env=mpt_after_env,
+ value_after_platform_clamp=mpt_after_platform,
+ final=cfg.network.max_peers_per_torrent,
+ env_ccbt_max_peers_per_torrent_set=env_ccbt_mpt_set,
+ windows_platform_clamp_applied_to_mpt=win_clamp_mpt,
+ )
+ return cfg
+
+ def simulate_load_from_file_dict(self, file_dict: dict[str, Any]) -> Config:
+ """Validate effective config as if the TOML file were ``file_dict``.
+
+ Merges environment overrides and applies the same Windows adjustments as
+ :meth:`_load_config`. Used by CLI ``config set`` / ``apply`` before persisting.
+
+ Args:
+ file_dict: Parsed TOML object representing the file to write.
+
+ Returns:
+ Validated ``Config`` instance.
+
+ Raises:
+ ConfigurationError: If the resulting configuration is invalid.
+
+ """
+ config_data = dict(file_dict)
+ self._normalize_loaded_config_data(config_data)
+ file_mpt = _snapshot_max_peers_per_torrent(config_data)
+ profile_enum = self._parse_optimization_profile_from_config_data(config_data)
+ self._merge_optimization_profile_into_config_data(config_data)
+ after_prof_mpt = _snapshot_max_peers_per_torrent(config_data)
+ return self._apply_env_windows_and_build_config(
+ config_data,
+ provenance_profile=profile_enum,
+ provenance_after_file_mpt=file_mpt,
+ provenance_after_profile_mpt=after_prof_mpt,
+ )
+
+ @staticmethod
+ def _parse_optimization_profile_from_config_data(
+ config_data: dict[str, Any],
+ ) -> OptimizationProfile:
+ """Read ``[optimization].profile`` from raw config before :class:`Config` exists."""
+ opt = config_data.get("optimization")
+ if not isinstance(opt, dict):
+ return OptimizationProfile.BALANCED
+ raw = opt.get("profile")
+ if raw is None:
+ return OptimizationProfile.BALANCED
+ if isinstance(raw, OptimizationProfile):
+ return raw
+ if isinstance(raw, str):
+ try:
+ return OptimizationProfile(raw.lower())
+ except ValueError as e:
+ msg = (
+ f"Invalid optimization profile: {raw!r}. "
+ f"Must be one of: {[p.value for p in OptimizationProfile]}"
+ )
+ raise ConfigurationError(msg) from e
+ msg = f"Invalid optimization profile type: {type(raw)!r}"
+ raise ConfigurationError(msg)
+
+ @staticmethod
+ def _merge_optimization_profile_into_config_data(
+ config_data: dict[str, Any],
+ ) -> None:
+ """Apply built-in profile overlays to ``config_data`` (file base already merged)."""
+ profile = ConfigManager._parse_optimization_profile_from_config_data(
+ config_data
+ )
+ if profile == OptimizationProfile.CUSTOM:
+ return
+
+ profile_config = _optimization_profile_overlays().get(profile)
+ if not profile_config:
+ msg = f"Profile {profile} not found in profile definitions"
+ raise ConfigurationError(msg)
+
+ for section, settings in profile_config.items():
+ if section == "strategy":
+ sec = config_data.setdefault("strategy", {})
+ if not isinstance(sec, dict):
+ msg = (
+ f"Invalid [strategy] section: expected dict, got {type(sec)!r}"
+ )
+ raise ConfigurationError(msg)
+ for key, value in settings.items():
+ sec[key] = value
+ elif section == "network":
+ sec = config_data.setdefault("network", {})
+ if not isinstance(sec, dict):
+ msg = f"Invalid [network] section: expected dict, got {type(sec)!r}"
+ raise ConfigurationError(msg)
+ for key, value in settings.items():
+ sec[key] = value
+ elif section == "discovery":
+ sec = config_data.setdefault("discovery", {})
+ if not isinstance(sec, dict):
+ msg = (
+ f"Invalid [discovery] section: expected dict, got {type(sec)!r}"
+ )
+ raise ConfigurationError(msg)
+ for key, value in settings.items():
+ sec[key] = value
+ elif section == "optimization":
+ sec = config_data.setdefault("optimization", {})
+ if not isinstance(sec, dict):
+ msg = f"Invalid [optimization] section: expected dict, got {type(sec)!r}"
+ raise ConfigurationError(msg)
+ for key, value in settings.items():
+ sec[key] = value
+
+ opt = config_data.setdefault("optimization", {})
+ if not isinstance(opt, dict):
+ msg = f"Invalid [optimization] section: expected dict, got {type(opt)!r}"
+ raise ConfigurationError(msg)
+ opt["profile"] = profile.value
+
+ def _load_config(self) -> Config:
+ """Load configuration: file → profile overlay → env (+ Windows clamp) → ``Config``."""
+ config_data: dict[str, Any] = {}
+
+ if self.config_file and self.config_file.exists():
+ try:
+ with open(self.config_file, encoding="utf-8") as f:
+ toml_data = toml.load(f)
+ config_data.update(toml_data)
+ self._normalize_loaded_config_data(config_data)
+ except Exception as e:
+ logging.warning(
+ "Failed to load config file %s: %s", self.config_file, e
+ )
+
+ file_mpt = _snapshot_max_peers_per_torrent(config_data)
+ profile_enum = self._parse_optimization_profile_from_config_data(config_data)
+ self._merge_optimization_profile_into_config_data(config_data)
+ after_prof_mpt = _snapshot_max_peers_per_torrent(config_data)
+ return self._apply_env_windows_and_build_config(
+ config_data,
+ provenance_profile=profile_enum,
+ provenance_after_file_mpt=file_mpt,
+ provenance_after_profile_mpt=after_prof_mpt,
+ )
+
def _get_env_config(self) -> dict[str, Any]:
"""Get configuration from environment variables."""
env_config: dict[str, Any] = {}
@@ -227,17 +694,51 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_XET_MULTICAST_ADDRESS": "network.xet_multicast_address",
"CCBT_XET_MULTICAST_PORT": "network.xet_multicast_port",
"CCBT_PIPELINE_DEPTH": "network.pipeline_depth",
+ "CCBT_SPARSE_PIPELINE_STALE_PAYLOAD_CANCEL_S": (
+ "network.sparse_pipeline_stale_payload_cancel_s"
+ ),
"CCBT_BLOCK_SIZE_KIB": "network.block_size_kib",
"CCBT_CONNECTION_TIMEOUT": "network.connection_timeout",
"CCBT_HANDSHAKE_TIMEOUT": "network.handshake_timeout",
+ "CCBT_HANDSHAKE_TIMEOUT_DESPERATION_MIN": "network.handshake_timeout_desperation_min",
+ "CCBT_HANDSHAKE_TIMEOUT_DESPERATION_MAX": "network.handshake_timeout_desperation_max",
+ "CCBT_HANDSHAKE_TIMEOUT_NORMAL_MIN": "network.handshake_timeout_normal_min",
+ "CCBT_HANDSHAKE_TIMEOUT_NORMAL_MAX": "network.handshake_timeout_normal_max",
+ "CCBT_HANDSHAKE_TIMEOUT_HEALTHY_MIN": "network.handshake_timeout_healthy_min",
+ "CCBT_HANDSHAKE_TIMEOUT_HEALTHY_MAX": "network.handshake_timeout_healthy_max",
+ "CCBT_HANDSHAKE_ADAPTIVE_TIMEOUT_ENABLED": "network.handshake_adaptive_timeout_enabled",
+ # false = deprecated legacy (always max timeout in desperation band)
+ "CCBT_HANDSHAKE_TIMEOUT_DESPERATION_INTERPOLATE": (
+ "network.handshake_timeout_desperation_interpolate"
+ ),
+ "CCBT_ADAPTIVE_TIMEOUT_HEALTH_PEER_SOURCE": (
+ "network.adaptive_timeout_health_peer_source"
+ ),
+ "CCBT_ADAPTIVE_TIMEOUT_DESPERATION_MAX_PEERS": (
+ "network.adaptive_timeout_desperation_max_peers"
+ ),
+ "CCBT_ADAPTIVE_TIMEOUT_NORMAL_MAX_PEERS": (
+ "network.adaptive_timeout_normal_max_peers"
+ ),
"CCBT_METADATA_EXCHANGE_TIMEOUT": "network.metadata_exchange_timeout",
+ "CCBT_PEER_QUALITY_PROBATION_TIMEOUT": "network.peer_quality_probation_timeout",
"CCBT_METADATA_PIECE_TIMEOUT": "network.metadata_piece_timeout",
+ "CCBT_BITFIELD_HAVE_WAIT_TIMEOUT_S": "network.bitfield_have_wait_timeout_s",
+ "CCBT_BITFIELD_HAVE_WAIT_METADATA_INCOMPLETE_MULTIPLIER": (
+ "network.bitfield_have_wait_metadata_incomplete_multiplier"
+ ),
"CCBT_CONNECTION_HEALTH_CHECK_INTERVAL": "network.connection_health_check_interval",
"CCBT_CONNECTION_VALIDATION_ENABLED": "network.connection_validation_enabled",
"CCBT_PEER_VALIDATION_ENABLED": "network.peer_validation_enabled",
"CCBT_SEND_BITFIELD_AFTER_METADATA": "network.send_bitfield_after_metadata",
"CCBT_SEND_INTERESTED_AFTER_METADATA": "network.send_interested_after_metadata",
"CCBT_MAX_CONCURRENT_CONNECTION_ATTEMPTS": "network.max_concurrent_connection_attempts",
+ "CCBT_CONNECT_TO_PEERS_PARALLEL_BATCHES": (
+ "network.connect_to_peers_parallel_batches"
+ ),
+ "CCBT_MSE_INITIATOR_TIMEOUT_SCALE_ZERO_ACTIVE": (
+ "network.mse_initiator_timeout_scale_zero_active"
+ ),
"CCBT_ENABLE_FAIL_FAST_DHT": "network.enable_fail_fast_dht",
"CCBT_FAIL_FAST_DHT_TIMEOUT": "network.fail_fast_dht_timeout",
"CCBT_KEEP_ALIVE_INTERVAL": "network.keep_alive_interval",
@@ -246,6 +747,116 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_PER_PEER_DOWN_KIB": "network.per_peer_down_kib",
"CCBT_PER_PEER_UP_KIB": "network.per_peer_up_kib",
"CCBT_MAX_UPLOAD_SLOTS": "network.max_upload_slots",
+ "CCBT_RECIPROCATION_CHOKED_PEER_SCORE_BOOST": (
+ "network.reciprocation_choked_peer_score_boost"
+ ),
+ "CCBT_RECIPROCATION_REMOTE_NOT_INTERESTED_BOOST": (
+ "network.reciprocation_remote_not_interested_boost"
+ ),
+ "CCBT_LOW_DOWNLOAD_DIVERSITY_THRESHOLD": (
+ "network.low_download_diversity_threshold"
+ ),
+ "CCBT_LOW_DOWNLOAD_DIVERSITY_FULL_UNCHOKE": (
+ "network.low_download_diversity_full_unchoke"
+ ),
+ "CCBT_LOW_DOWNLOAD_DIVERSITY_USE_HYSTERESIS": (
+ "network.low_download_diversity_use_hysteresis"
+ ),
+ "CCBT_LOW_DOWNLOAD_DIVERSITY_EXIT_MARGIN": (
+ "network.low_download_diversity_exit_margin"
+ ),
+ "CCBT_LOW_DOWNLOAD_DIVERSITY_MAX_PEERS": (
+ "network.low_download_diversity_max_peers"
+ ),
+ "CCBT_LEECH_HEAVY_SWARM_TOTAL_UPLOAD_BPS_THRESHOLD": (
+ "network.leech_heavy_swarm_total_upload_bps_threshold"
+ ),
+ "CCBT_INBOUND_UNKNOWN_HASH_WARNING_SAMPLE_INTERVAL": (
+ "network.inbound_unknown_hash_warning_sample_interval"
+ ),
+ "CCBT_INBOUND_MAX_PROBATION_INFLIGHT_PER_HASH": (
+ "network.inbound_max_probation_inflight_per_hash"
+ ),
+ "CCBT_INBOUND_REGISTRATION_WAIT_CAP_NO_SESSIONS_S": (
+ "network.inbound_registration_wait_cap_no_sessions_s"
+ ),
+ "CCBT_INBOUND_REGISTRATION_WAIT_CAP_DEFAULT_S": (
+ "network.inbound_registration_wait_cap_default_s"
+ ),
+ "CCBT_INBOUND_REGISTRATION_WAIT_CAP_STORM_S": (
+ "network.inbound_registration_wait_cap_storm_s"
+ ),
+ "CCBT_INBOUND_REGISTRATION_WAIT_CAP_METADATA_PENDING_S": (
+ "network.inbound_registration_wait_cap_metadata_pending_s"
+ ),
+ "CCBT_INBOUND_GRACE_POLL_SECONDS_NO_SESSIONS_S": (
+ "network.inbound_grace_poll_seconds_no_sessions_s"
+ ),
+ "CCBT_INBOUND_GRACE_POLL_SECONDS_STORM_S": (
+ "network.inbound_grace_poll_seconds_storm_s"
+ ),
+ "CCBT_INBOUND_GRACE_POLL_SECONDS_DEFAULT_S": (
+ "network.inbound_grace_poll_seconds_default_s"
+ ),
+ "CCBT_INBOUND_PROBATION_WINDOW_S": "network.inbound_probation_window_s",
+ "CCBT_INBOUND_PROBATION_WINDOW_STORM_S": (
+ "network.inbound_probation_window_storm_s"
+ ),
+ "CCBT_INBOUND_PROBATION_RETRY_INTERVAL_S": (
+ "network.inbound_probation_retry_interval_s"
+ ),
+ "CCBT_INBOUND_UNKNOWN_HASH_STORM_THRESHOLD": (
+ "network.inbound_unknown_hash_storm_threshold"
+ ),
+ "CCBT_INBOUND_PROBATION_WAIT_QUEUE_MAX_TOTAL": (
+ "network.inbound_probation_wait_queue_max_total"
+ ),
+ "CCBT_INBOUND_PROBATION_QUEUED_MAX_WAIT_S": (
+ "network.inbound_probation_queued_max_wait_s"
+ ),
+ "CCBT_CHOKE_ONLY_SLOT_REPLACEMENT_ENABLED": (
+ "network.choke_only_slot_replacement_enabled"
+ ),
+ "CCBT_CHOKE_ONLY_SLOT_REPLACEMENT_MIN_ACTIVE_PEERS": (
+ "network.choke_only_slot_replacement_min_active_peers"
+ ),
+ "CCBT_CHOKE_ONLY_SLOT_REPLACEMENT_MIN_CHOKE_RATIO": (
+ "network.choke_only_slot_replacement_min_choke_ratio"
+ ),
+ "CCBT_CHOKE_ONLY_SLOT_REPLACEMENT_MAX_DISCONNECT_FRACTION": (
+ "network.choke_only_slot_replacement_max_disconnect_fraction"
+ ),
+ "CCBT_CHOKE_ONLY_SLOT_REPLACEMENT_AT_LIMIT_FRACTION": (
+ "network.choke_only_slot_replacement_at_limit_fraction"
+ ),
+ "CCBT_RECIPROCATION_MAX_COMBINED_BOOST": (
+ "network.reciprocation_max_combined_boost"
+ ),
+ "CCBT_OPTIMISTIC_UNCHOKE_TOP_CANDIDATES": (
+ "network.optimistic_unchoke_top_candidates"
+ ),
+ "CCBT_OPTIMISTIC_UNCHOKE_USE_JITTER": (
+ "network.optimistic_unchoke_use_jitter"
+ ),
+ "CCBT_PEER_CHOKED_HARD_TIMEOUT_SECONDS": (
+ "network.peer_choked_hard_timeout_seconds"
+ ),
+ "CCBT_PEER_CHOKED_ANCHOR_TIMEOUT_SECONDS": (
+ "network.peer_choked_anchor_timeout_seconds"
+ ),
+ "CCBT_PEER_CHOKED_SOLO_GRACE_SECONDS": (
+ "network.peer_choked_solo_grace_seconds"
+ ),
+ "CCBT_PEER_CHOKED_SOLO_GRACE_ZERO_BYTES_CAP_SECONDS": (
+ "network.peer_choked_solo_grace_zero_bytes_cap_seconds"
+ ),
+ "CCBT_PEER_QUALITY_PROBATION_SPARSE_CHOKE_GRACE_SECONDS": (
+ "network.peer_quality_probation_sparse_choke_grace_seconds"
+ ),
+ "CCBT_PEER_RECYCLE_SPARSE_BACKOFF_CAP_SECONDS": (
+ "network.peer_recycle_sparse_backoff_cap_seconds"
+ ),
+ "CCBT_RECYCLE_PRESSURE_THRESHOLD": "network.recycle_pressure_threshold",
"CCBT_TRACKER_TIMEOUT": "network.tracker_timeout",
"CCBT_DNS_CACHE_TTL": "network.dns_cache_ttl",
# Connection pool
@@ -271,6 +882,18 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_TRACKER_KEEPALIVE_TIMEOUT": "network.tracker_keepalive_timeout",
"CCBT_TRACKER_ENABLE_DNS_CACHE": "network.tracker_enable_dns_cache",
"CCBT_TRACKER_DNS_CACHE_TTL": "network.tracker_dns_cache_ttl",
+ "CCBT_TRACKER_NETWORK_FAILURE_QUARANTINE_SECONDS": (
+ "network.tracker_network_failure_quarantine_seconds"
+ ),
+ "CCBT_TRACKER_PAYLOAD_FAILURE_QUARANTINE_SECONDS": (
+ "network.tracker_payload_failure_quarantine_seconds"
+ ),
+ "CCBT_TRACKER_DNS_REFUSED_ESCALATION_STREAK": (
+ "network.tracker_dns_refused_escalation_streak"
+ ),
+ "CCBT_TRACKER_ZERO_ACTIVE_BATCHES_BEFORE_DHT_SHORT_CIRCUIT": (
+ "network.tracker_zero_active_batches_before_dht_short_circuit"
+ ),
# Timeout and retry
"CCBT_TIMEOUT_ADAPTIVE": "network.timeout_adaptive",
"CCBT_TIMEOUT_MIN_SECONDS": "network.timeout_min_seconds",
@@ -313,6 +936,9 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_BANDWIDTH_WEIGHTED_RAREST_WEIGHT": "strategy.bandwidth_weighted_rarest_weight",
"CCBT_PROGRESSIVE_RAREST_TRANSITION_THRESHOLD": "strategy.progressive_rarest_transition_threshold",
"CCBT_ADAPTIVE_HYBRID_PHASE_DETECTION_WINDOW": "strategy.adaptive_hybrid_phase_detection_window",
+ "CCBT_PEER_SELECTOR_ML_RANKING_WEIGHT": (
+ "strategy.peer_selector_ml_ranking_weight"
+ ),
# Disk
"CCBT_PREALLOCATE": "disk.preallocate",
"CCBT_USE_MMAP": "disk.use_mmap",
@@ -359,6 +985,9 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_TRACKER_ANNOUNCE_INTERVAL": "discovery.tracker_announce_interval",
"CCBT_TRACKER_SCRAPE_INTERVAL": "discovery.tracker_scrape_interval",
"CCBT_TRACKER_AUTO_SCRAPE": "discovery.tracker_auto_scrape",
+ "CCBT_TRACKER_STOPPED_ANNOUNCE_TIMEOUT_S": (
+ "discovery.tracker_stopped_announce_timeout_s"
+ ),
"CCBT_TRACKER_ADAPTIVE_INTERVAL_ENABLED": "discovery.tracker_adaptive_interval_enabled",
"CCBT_TRACKER_ADAPTIVE_INTERVAL_MIN": "discovery.tracker_adaptive_interval_min",
"CCBT_TRACKER_ADAPTIVE_INTERVAL_MAX": "discovery.tracker_adaptive_interval_max",
@@ -366,6 +995,28 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_TRACKER_PEER_COUNT_WEIGHT": "discovery.tracker_peer_count_weight",
"CCBT_TRACKER_PERFORMANCE_WEIGHT": "discovery.tracker_performance_weight",
"CCBT_DEFAULT_TRACKERS": "discovery.default_trackers",
+ "CCBT_TRACKER_UDP_PENDING_SOFT_CAP_PER_HOST": (
+ "discovery.tracker_udp_pending_soft_cap_per_host"
+ ),
+ "CCBT_TRACKER_UDP_MAX_PENDING_REQUESTS": (
+ "discovery.tracker_udp_max_pending_requests"
+ ),
+ "CCBT_TRACKER_UDP_WAIT_PACING_LOAD_RATIO": (
+ "discovery.tracker_udp_wait_pacing_load_ratio"
+ ),
+ "CCBT_TRACKER_INGRESS_HOLD_PENDING_QUEUE_THRESHOLD": (
+ "discovery.tracker_ingress_hold_pending_queue_threshold"
+ ),
+ # Deprecated to set false: legacy peer ordering; default true is supported path.
+ "CCBT_STRICT_TRACKER_SOURCE_CONNECT_PRIORITY": (
+ "discovery.strict_tracker_source_connect_priority"
+ ),
+ "CCBT_STRICT_TRACKER_PENDING_DHT_PEX_BOOST": (
+ "discovery.strict_tracker_pending_dht_pex_boost"
+ ),
+ "CCBT_STRICT_TRACKER_PENDING_TRACKER_PREFIX": (
+ "discovery.strict_tracker_pending_tracker_prefix"
+ ),
"CCBT_PEX_INTERVAL": "discovery.pex_interval",
"CCBT_STRICT_PRIVATE_MODE": "discovery.strict_private_mode",
# BEP 32: IPv6 Extension for DHT
@@ -400,6 +1051,13 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_DHT_ADAPTIVE_INTERVAL_MAX": "discovery.dht_adaptive_interval_max",
"CCBT_DHT_QUALITY_TRACKING_ENABLED": "discovery.dht_quality_tracking_enabled",
"CCBT_DHT_QUALITY_RESPONSE_TIME_WINDOW": "discovery.dht_quality_response_time_window",
+ "CCBT_DHT_ADAPTIVE_TIMEOUT_ENABLED": "discovery.dht_adaptive_timeout_enabled",
+ "CCBT_DHT_TIMEOUT_DESPERATION_MIN": "discovery.dht_timeout_desperation_min",
+ "CCBT_DHT_TIMEOUT_DESPERATION_MAX": "discovery.dht_timeout_desperation_max",
+ "CCBT_DHT_TIMEOUT_NORMAL_MIN": "discovery.dht_timeout_normal_min",
+ "CCBT_DHT_TIMEOUT_NORMAL_MAX": "discovery.dht_timeout_normal_max",
+ "CCBT_DHT_TIMEOUT_HEALTHY_MIN": "discovery.dht_timeout_healthy_min",
+ "CCBT_DHT_TIMEOUT_HEALTHY_MAX": "discovery.dht_timeout_healthy_max",
# DHT query parameters (Kademlia algorithm)
"CCBT_DHT_NORMAL_ALPHA": "discovery.dht_normal_alpha",
"CCBT_DHT_NORMAL_K": "discovery.dht_normal_k",
@@ -407,6 +1065,58 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_DHT_AGGRESSIVE_ALPHA": "discovery.dht_aggressive_alpha",
"CCBT_DHT_AGGRESSIVE_K": "discovery.dht_aggressive_k",
"CCBT_DHT_AGGRESSIVE_MAX_DEPTH": "discovery.dht_aggressive_max_depth",
+ "CCBT_DHT_BOOTSTRAP_NODES": "discovery.dht_bootstrap_nodes",
+ "CCBT_BOOTSTRAP_SEED_REPLAY_LIMIT": "discovery.bootstrap_seed_replay_limit",
+ "CCBT_DHT_BOOTSTRAP_RETRIES_MAX": "discovery.dht_bootstrap_retries_max",
+ "CCBT_BOOTSTRAP_RETRY_MEMO_TTL_S": "discovery.bootstrap_retry_memo_ttl_s",
+ "CCBT_DHT_BOOTSTRAP_MEMO_TTL_S": "discovery.dht_bootstrap_memo_ttl_s",
+ "CCBT_DHT_DNS_HOST_BACKOFF_INITIAL_S": (
+ "discovery.dht_dns_host_backoff_initial_s"
+ ),
+ "CCBT_DHT_DNS_HOST_BACKOFF_MAX_S": "discovery.dht_dns_host_backoff_max_s",
+ "CCBT_DHT_DNS_HOST_BACKOFF_MULTIPLIER": (
+ "discovery.dht_dns_host_backoff_multiplier"
+ ),
+ "CCBT_DHT_ZERO_STATE_REPROBE_WAIT_S": "discovery.dht_zero_state_reprobe_wait_s",
+ "CCBT_DHT_EMPTY_STATE_BACKOFF_FACTOR": "discovery.dht_empty_state_backoff_factor",
+ "CCBT_DHT_REBOOTSTRAP_TIMEOUT_S": "discovery.dht_rebootstrap_timeout_s",
+ "CCBT_DHT_BOOTSTRAP_TIMEOUT_S": "discovery.dht_bootstrap_timeout_s",
+ "CCBT_LOW_PEER_THRESHOLD": "discovery.low_peer_threshold",
+ "CCBT_LOW_PEER_SUPPRESSION_WINDOW_S": "discovery.low_peer_suppression_window_s",
+ "CCBT_PEER_COUNT_LOW_SKIP_DHT_REQUIRES_USABLE_PATH": (
+ "discovery.peer_count_low_skip_dht_requires_usable_path"
+ ),
+ "CCBT_REQUESTABLE_DRIVEN_DISCOVERY_ENABLED": (
+ "discovery.requestable_driven_discovery_enabled"
+ ),
+ "CCBT_TARGET_REQUESTABLE_PEERS": "discovery.target_requestable_peers",
+ "CCBT_REQUESTABLE_TICK_INTERVAL_S": "discovery.requestable_tick_interval_s",
+ "CCBT_REQUESTABLE_FORCE_DHT_WHEN_ZERO": (
+ "discovery.requestable_force_dht_when_zero"
+ ),
+ "CCBT_MAX_CONNECT_BURST_PER_TICK": "discovery.max_connect_burst_per_tick",
+ "CCBT_TRACKER_IMMEDIATE_CONNECT_BURST_TOTAL": (
+ "discovery.tracker_immediate_connect_burst_total"
+ ),
+ "CCBT_TRACKER_IMMEDIATE_CONNECT_BURST_PER_SOURCE": (
+ "discovery.tracker_immediate_connect_burst_per_source"
+ ),
+ "CCBT_TRACKER_IMMEDIATE_CONNECT_WINDOW_S": (
+ "discovery.tracker_immediate_connect_window_s"
+ ),
+ "CCBT_TRACKER_IMMEDIATE_CONNECT_WINDOW_CAP": (
+ "discovery.tracker_immediate_connect_window_cap"
+ ),
+ "CCBT_TRACKER_IMMEDIATE_PER_SOURCE_CAP_MODE": (
+ "discovery.tracker_immediate_per_source_cap_mode"
+ ),
+ "CCBT_TRACKER_IMMEDIATE_PER_TRACKER_COOLDOWN_ENABLED": (
+ "discovery.tracker_immediate_per_tracker_cooldown_enabled"
+ ),
+ "CCBT_MAX_TRACKER_URLS_PER_TORRENT": "discovery.max_tracker_urls_per_torrent",
+ "CCBT_ANNOUNCE_MAX_TRACKERS_PER_ROUND": (
+ "discovery.announce_max_trackers_per_round"
+ ),
# XET chunk discovery
"CCBT_XET_CHUNK_QUERY_BATCH_SIZE": "discovery.xet_chunk_query_batch_size",
"CCBT_XET_CHUNK_QUERY_MAX_CONCURRENT": "discovery.xet_chunk_query_max_concurrent",
@@ -423,8 +1133,11 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_MEDIA_VLC_EXECUTABLE_PATH": "media.vlc_executable_path",
"CCBT_ENABLE_INLINE_MEDIA_PREVIEW": "media.enable_inline_media_preview",
"CCBT_INLINE_MEDIA_PREVIEW_MODE": "media.inline_media_preview_mode",
- # Security
+ # Security / MSE-PE (BEP 3). Canonical toggle is security.enable_encryption;
+ # CCBT_NETWORK_ENABLE_ENCRYPTION mirrors [network] enable_encryption in ccbt.toml
+ # (merged into security by Config model validation).
"CCBT_ENABLE_ENCRYPTION": "security.enable_encryption",
+ "CCBT_NETWORK_ENABLE_ENCRYPTION": "network.enable_encryption",
"CCBT_ENCRYPTION_MODE": "security.encryption_mode",
"CCBT_ENCRYPTION_DH_KEY_SIZE": "security.encryption_dh_key_size",
"CCBT_ENCRYPTION_PREFER_RC4": "security.encryption_prefer_rc4",
@@ -434,6 +1147,34 @@ def _get_env_config(self) -> dict[str, Any]:
"CCBT_RATE_LIMIT_ENABLED": "security.rate_limit_enabled",
"CCBT_MAX_CONNECTIONS_PER_PEER": "security.max_connections_per_peer",
"CCBT_PEER_QUALITY_THRESHOLD": "security.peer_quality_threshold",
+ "CCBT_AUTHENTICATED_SWARMS_MODE": "security.authenticated_swarms.mode",
+ "CCBT_AUTHENTICATED_SWARMS_DISCOVERY_MODE": (
+ "security.authenticated_swarms.discovery_mode"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_DISCOVERY_STRICT_FOR_STRICT_MODE": (
+ "security.authenticated_swarms.discovery_strict_for_strict_mode"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_STRICT_LTEP_TIMEOUT_S": (
+ "security.authenticated_swarms.strict_ltep_handshake_timeout_s"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_TRUSTED_IDS": (
+ "security.authenticated_swarms.trusted_swarm_ids"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_FAIL_CLOSED_ON_PARSE_ERRORS": (
+ "security.authenticated_swarms.fail_closed_on_parse_errors"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_TRUST_STORE_PATH": (
+ "security.authenticated_swarms.trust_store_path"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_TRUST_STORE_REFRESH_INTERVAL_S": (
+ "security.authenticated_swarms.trust_store_refresh_interval_s"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_REVOCATION_PROFILE_PATH": (
+ "security.authenticated_swarms.revocation_profile_path"
+ ),
+ "CCBT_AUTHENTICATED_SWARMS_REVOCATION_REFRESH_INTERVAL_S": (
+ "security.authenticated_swarms.revocation_refresh_interval_s"
+ ),
# IP Filter
"CCBT_ENABLE_IP_FILTER": "security.ip_filter.enable_ip_filter",
"CCBT_FILTER_MODE": "security.ip_filter.filter_mode",
@@ -458,9 +1199,14 @@ def _get_env_config(self) -> dict[str, Any]:
# Observability
"CCBT_LOG_LEVEL": "observability.log_level",
"CCBT_LOG_FILE": "observability.log_file",
+ "CCBT_LOG_FORMAT": "observability.log_format",
+ "CCBT_LOG_CORRELATION_ID": "observability.log_correlation_id",
"CCBT_ENABLE_METRICS": "observability.enable_metrics",
+ "CCBT_METRICS_INTERVAL": "observability.metrics_interval",
"CCBT_METRICS_PORT": "observability.metrics_port",
"CCBT_ENABLE_PEER_TRACING": "observability.enable_peer_tracing",
+ "CCBT_STRUCTURED_LOGGING": "observability.structured_logging",
+ "CCBT_TRACE_FILE": "observability.trace_file",
# Event bus configuration
"CCBT_EVENT_BUS_MAX_QUEUE_SIZE": "observability.event_bus_max_queue_size",
"CCBT_EVENT_BUS_BATCH_SIZE": "observability.event_bus_batch_size",
@@ -579,17 +1325,7 @@ def _parse_env_value(
raw: str, path: str
) -> Union[bool, int, float, str, list[str]]:
# Handle list values (comma-separated strings)
- if path == "security.encryption_allowed_ciphers":
- return [item.strip() for item in raw.split(",") if item.strip()]
- if path in {
- "security.ip_filter.filter_files",
- "security.ip_filter.filter_urls",
- "security.blacklist.auto_update_sources",
- "discovery.dht_bootstrap_nodes",
- "discovery.dht_ipv6_bootstrap_nodes",
- "discovery.default_trackers",
- "proxy.proxy_bypass_list",
- }:
+ if path in COMMA_SEPARATED_LIST_PATHS:
return [item.strip() for item in raw.split(",") if item.strip()]
low = raw.lower()
@@ -685,6 +1421,42 @@ def export(self, fmt: str = "toml", encrypt_passwords: bool = True) -> str:
msg = f"Unsupported export format: {fmt}" # pragma: no cover
raise ConfigurationError(msg) # pragma: no cover
+ def get_runtime_env_diagnostics(self) -> dict[str, Any]:
+ """Return runtime env + dotenv provenance diagnostics for support reports."""
+ import os
+
+ return {
+ "dotenv_loader_requested": str(os.getenv("CCBT_LOAD_DOTENV", "")).strip(),
+ "dotenv_loaded": str(os.getenv("CCBT_DOTENV_LOADED", "0")).strip(),
+ "dotenv_path_effective": str(
+ os.getenv("CCBT_DOTENV_PATH_EFFECTIVE", "")
+ ).strip(),
+ "dotenv_keys_loaded": str(
+ os.getenv("CCBT_DOTENV_KEYS_LOADED", "0")
+ ).strip(),
+ "max_peers_per_torrent_effective": int(
+ getattr(self.config.network, "max_peers_per_torrent", 0) or 0
+ ),
+ "tracker_immediate_connect_burst_total": int(
+ getattr(
+ self.config.discovery,
+ "tracker_immediate_connect_burst_total",
+ 0,
+ )
+ or 0
+ ),
+ "tracker_immediate_per_tracker_cooldown_enabled": bool(
+ getattr(
+ self.config.discovery,
+ "tracker_immediate_per_tracker_cooldown_enabled",
+ True,
+ )
+ ),
+ "target_requestable_peers": int(
+ getattr(self.config.discovery, "target_requestable_peers", 0) or 0
+ ),
+ }
+
def save_config(self) -> None:
"""Save current configuration to file.
@@ -845,7 +1617,11 @@ def _decrypt_proxy_password(self, encrypted: str) -> str:
def _setup_logging(self) -> None:
"""Set up logging configuration."""
- setup_logging(self.config.observability)
+ override = get_cli_session_log_level_override()
+ setup_logging(
+ self.config.observability,
+ effective_log_level=override,
+ )
async def start_hot_reload(self) -> None:
"""Start hot-reload monitoring."""
@@ -1012,98 +1788,11 @@ def apply_profile(
)
raise ConfigurationError(msg) from e
- # Profile definitions
- profiles = {
- OptimizationProfile.BALANCED: {
- "strategy": {
- "piece_selection": "rarest_first",
- "pipeline_capacity": 4,
- "endgame_duplicates": 2,
- },
- "network": {
- "max_connections_per_torrent": 50,
- "max_global_peers": 200,
- },
- "discovery": {
- "tracker_announce_interval": 60.0,
- },
- "optimization": {
- "enable_adaptive_intervals": True,
- "enable_performance_based_recycling": True,
- "enable_bandwidth_aware_scheduling": True,
- },
- },
- OptimizationProfile.SPEED: {
- "strategy": {
- "piece_selection": "bandwidth_weighted_rarest",
- "pipeline_capacity": 8,
- "endgame_duplicates": 3,
- },
- "network": {
- "max_connections_per_torrent": 100,
- "max_global_peers": 500,
- },
- "discovery": {
- "tracker_announce_interval": 30.0,
- },
- "optimization": {
- "enable_adaptive_intervals": True,
- "enable_performance_based_recycling": True,
- "speed_aggressive_peer_recycling": True,
- "enable_bandwidth_aware_scheduling": True,
- },
- },
- OptimizationProfile.EFFICIENCY: {
- "strategy": {
- "piece_selection": "adaptive_hybrid",
- "pipeline_capacity": 6,
- "endgame_duplicates": 2,
- },
- "network": {
- "max_connections_per_torrent": 30,
- "max_global_peers": 150,
- },
- "discovery": {
- "tracker_announce_interval": 90.0,
- },
- "optimization": {
- "enable_adaptive_intervals": True,
- "enable_performance_based_recycling": True,
- "efficiency_connection_limit_multiplier": 0.8,
- "enable_bandwidth_aware_scheduling": True,
- },
- },
- OptimizationProfile.LOW_RESOURCE: {
- "strategy": {
- "piece_selection": "rarest_first",
- "pipeline_capacity": 2,
- "endgame_duplicates": 1,
- },
- "network": {
- "max_connections_per_torrent": 10,
- "max_global_peers": 50,
- },
- "discovery": {
- "tracker_announce_interval": 120.0,
- },
- "optimization": {
- "enable_adaptive_intervals": False,
- "enable_performance_based_recycling": False,
- "low_resource_max_connections": 20,
- "enable_bandwidth_aware_scheduling": False,
- },
- },
- OptimizationProfile.CUSTOM: {
- # CUSTOM profile doesn't override anything
- # User has full control via config file
- },
- }
-
if profile == OptimizationProfile.CUSTOM:
# Don't apply any overrides for CUSTOM profile
return
- profile_config = profiles.get(profile)
+ profile_config = _optimization_profile_overlays().get(profile)
if not profile_config:
msg = f"Profile {profile} not found in profile definitions"
raise ConfigurationError(msg)
@@ -1116,8 +1805,13 @@ def apply_profile(
setattr(self.config.strategy, key, value)
elif section == "network":
for key, value in settings.items():
- if hasattr(self.config.network, key):
- setattr(self.config.network, key, value)
+ if not hasattr(self.config.network, key):
+ msg = (
+ f"Optimization profile {profile.value} contains unknown "
+ f"network key '{key}'"
+ )
+ raise ConfigurationError(msg)
+ setattr(self.config.network, key, value)
elif section == "discovery":
for key, value in settings.items():
if hasattr(self.config.discovery, key):
@@ -1153,6 +1847,13 @@ def get_config() -> Config:
return _config_manager.config
+def get_max_peers_per_torrent_provenance() -> Optional[MaxPeersPerTorrentProvenance]:
+ """Return last recorded ``max_peers_per_torrent`` resolution chain, if config was loaded here."""
+ if _config_manager is None:
+ return None
+ return _config_manager.max_peers_per_torrent_provenance
+
+
def init_config(config_file: Optional[Union[str, Path]] = None) -> ConfigManager:
"""Initialize the global configuration manager."""
return ConfigManager(config_file)
@@ -1190,6 +1891,7 @@ def reset_config() -> None:
"""
global _config_manager
_config_manager = None
+ set_cli_session_log_level_override(None)
# Backward compatibility functions
diff --git a/ccbt/config/config_cli_values.py b/ccbt/config/config_cli_values.py
new file mode 100644
index 00000000..497b8d9c
--- /dev/null
+++ b/ccbt/config/config_cli_values.py
@@ -0,0 +1,90 @@
+"""Parse CLI-provided configuration values for dotted config paths.
+
+Shared with environment parsing semantics where list fields accept comma-separated
+strings.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+
+# Paths where a comma-separated string should become list[str] (aligned with
+# ``ConfigManager._get_env_config`` / ``_parse_env_value``).
+COMMA_SEPARATED_LIST_PATHS: frozenset[str] = frozenset(
+ {
+ "security.encryption_allowed_ciphers",
+ "security.ip_filter.filter_files",
+ "security.ip_filter.filter_urls",
+ "security.blacklist.auto_update_sources",
+ "security.authenticated_swarms.trusted_swarm_ids",
+ "discovery.dht_bootstrap_nodes",
+ "discovery.dht_ipv6_bootstrap_nodes",
+ "discovery.default_trackers",
+ "proxy.proxy_bypass_list",
+ }
+)
+
+
+def parse_cli_config_value(raw: str, dotted_path: str) -> Any:
+ """Parse a CLI string into a Python value suitable for merging into config data.
+
+ Order:
+
+ 1. ``json.loads`` when the string decodes as JSON (arrays, objects, numbers,
+ booleans, null).
+ 2. Path-aware comma-separated list for :data:`COMMA_SEPARATED_LIST_PATHS`.
+ 3. Boolean tokens, then int/float, else raw string.
+
+ Args:
+ raw: Raw argument text (often from ``--value``).
+ dotted_path: Full dotted config key (e.g. ``network.listen_port``).
+
+ Returns:
+ Parsed value.
+
+ """
+ stripped = raw.strip()
+ if stripped:
+ try:
+ return json.loads(stripped)
+ except (json.JSONDecodeError, TypeError, ValueError):
+ pass
+
+ if dotted_path in COMMA_SEPARATED_LIST_PATHS:
+ return [item.strip() for item in raw.split(",") if item.strip()]
+
+ low = raw.lower()
+ if low in {"true", "1", "yes", "on"}:
+ return True
+ if low in {"false", "0", "no", "off"}:
+ return False
+ try:
+ if "." in raw:
+ return float(raw)
+ return int(raw)
+ except ValueError:
+ return raw
+
+
+def get_nested_value(data: dict[str, Any], dotted_path: str) -> Any:
+ """Return value at dotted path in a nested dict, or ``None`` if missing."""
+ cur: Any = data
+ for part in dotted_path.split("."):
+ if not isinstance(cur, dict) or part not in cur:
+ return None
+ cur = cur[part]
+ return cur
+
+
+def set_nested_dict(target: dict[str, Any], dotted_path: str, value: Any) -> None:
+ """Set ``target[a][b]... = value`` for a dotted path, creating dicts as needed."""
+ parts = dotted_path.split(".")
+ cur = target
+ for p in parts[:-1]:
+ nxt = cur.get(p)
+ if not isinstance(nxt, dict):
+ nxt = {}
+ cur[p] = nxt
+ cur = nxt
+ cur[parts[-1]] = value
diff --git a/ccbt/config/config_migration.py b/ccbt/config/config_migration.py
index 4b60638c..b7608516 100644
--- a/ccbt/config/config_migration.py
+++ b/ccbt/config/config_migration.py
@@ -136,6 +136,7 @@ def _migrate_0_8_0_to_1_0_0(config_data: dict[str, Any]) -> dict[str, Any]:
"""
migrated = config_data.copy()
+ migrated = ConfigMigrator._migrate_legacy_security_fields(migrated)
# Add missing sections with defaults
if "limits" not in migrated:
@@ -150,7 +151,7 @@ def _migrate_0_8_0_to_1_0_0(config_data: dict[str, Any]) -> dict[str, Any]:
if "security" not in migrated:
migrated["security"] = {
"enable_encryption": False,
- "encryption_preference": "allow_plaintext",
+ "encryption_mode": "preferred",
"validate_peers": True,
"peer_validation_timeout": 30,
"rate_limit_enabled": True,
@@ -182,6 +183,7 @@ def _migrate_0_9_0_to_1_0_0(config_data: dict[str, Any]) -> dict[str, Any]:
"""
migrated = config_data.copy()
+ migrated = ConfigMigrator._migrate_legacy_security_fields(migrated)
# Move global limits from network to limits section
if "network" in migrated and "limits" not in migrated:
@@ -198,7 +200,7 @@ def _migrate_0_9_0_to_1_0_0(config_data: dict[str, Any]) -> dict[str, Any]:
if "security" not in migrated:
migrated["security"] = {
"enable_encryption": False,
- "encryption_preference": "allow_plaintext",
+ "encryption_mode": "preferred",
"validate_peers": True,
"peer_validation_timeout": 30,
"rate_limit_enabled": True,
@@ -218,6 +220,41 @@ def _migrate_0_9_0_to_1_0_0(config_data: dict[str, Any]) -> dict[str, Any]:
return migrated
+ @staticmethod
+ def _migrate_legacy_security_fields(config_data: dict[str, Any]) -> dict[str, Any]:
+ """Map legacy security-related fields to canonical SecurityConfig keys."""
+ security = config_data.get("security")
+ if not isinstance(security, dict):
+ security = {}
+ config_data["security"] = security
+
+ legacy_pref = security.pop("encryption_preference", None)
+ if legacy_pref is not None and "encryption_mode" not in security:
+ pref_key = str(legacy_pref).lower().strip().replace(" ", "_")
+ pref_to_mode = {
+ "allow_plaintext": "preferred",
+ "prefer_encrypted": "preferred",
+ "require_encrypted": "required",
+ "disabled": "disabled",
+ }
+ security["encryption_mode"] = pref_to_mode.get(pref_key, "preferred")
+
+ network = config_data.get("network")
+ if (
+ isinstance(network, dict)
+ and "enable_encryption" in network
+ and "enable_encryption" not in security
+ ):
+ security["enable_encryption"] = bool(network["enable_encryption"])
+
+ if "encryption_mode" not in security:
+ security["encryption_mode"] = "preferred"
+
+ if "enable_encryption" not in security:
+ security["enable_encryption"] = False
+
+ return config_data
+
@staticmethod
def migrate_file(
config_file: Union[Path, str],
diff --git a/ccbt/config/config_schema.py b/ccbt/config/config_schema.py
index 449516c9..4e0441a6 100644
--- a/ccbt/config/config_schema.py
+++ b/ccbt/config/config_schema.py
@@ -17,6 +17,67 @@
logger = logging.getLogger(__name__)
+def _resolve_json_schema_ref(
+ node: dict[str, Any], root_schema: dict[str, Any]
+) -> dict[str, Any]:
+ """Resolve a JSON Schema node following a single ``#/$defs/Name`` ``$ref``."""
+ ref = node.get("$ref")
+ if not ref or not isinstance(ref, str):
+ return node
+ if ref.startswith("#/$defs/"):
+ def_name = ref.removeprefix("#/$defs/")
+ resolved = root_schema.get("$defs", {}).get(def_name)
+ if isinstance(resolved, dict):
+ return resolved
+ return node
+
+
+def _unwrap_optional_object_schema(
+ node: dict[str, Any], root_schema: dict[str, Any]
+) -> dict[str, Any]:
+ """Follow ``$ref`` and optional ``anyOf``/``oneOf`` (e.g. ``T | null``) to an object schema."""
+ cur = _resolve_json_schema_ref(node, root_schema)
+ for union_key in ("anyOf", "oneOf"):
+ branches = cur.get(union_key)
+ if not isinstance(branches, list):
+ continue
+ for br in branches:
+ if not isinstance(br, dict):
+ continue
+ if br == {"type": "null"}:
+ continue
+ resolved = _resolve_json_schema_ref(br, root_schema)
+ if isinstance(resolved.get("properties"), dict):
+ return resolved
+ return cur
+
+
+def _schema_type_label(node: dict[str, Any]) -> str:
+ """Human-readable type label for a JSON Schema fragment."""
+ t = node.get("type")
+ if isinstance(t, list):
+ return "|".join(str(x) for x in t)
+ if t:
+ return str(t)
+ if "enum" in node:
+ return "enum"
+ if "anyOf" in node or "oneOf" in node:
+ return "union"
+ if "$ref" in node:
+ return "ref"
+ return "unknown"
+
+
+def _get_nested_default(model_defaults: dict[str, Any], path: str) -> Any:
+ """Return value at dotted path in a nested dict, or ``None`` if missing."""
+ cur: Any = model_defaults
+ for part in path.split("."):
+ if not isinstance(cur, dict) or part not in cur:
+ return None
+ cur = cur[part]
+ return cur
+
+
class ConfigSchema:
"""Configuration schema generation utilities."""
@@ -65,15 +126,10 @@ def get_schema_for_section(section_name: str) -> Optional[dict[str, Any]]:
if not section_ref:
return None
- # If it's a reference, resolve it
- if "$ref" in section_ref:
- ref_path = section_ref["$ref"]
- if ref_path.startswith("#/$defs/"):
- def_name = ref_path[8:] # Remove "#/$defs/"
- definitions = full_schema.get("$defs", {})
- return definitions.get(def_name)
+ if not isinstance(section_ref, dict):
+ return None
- return section_ref
+ return _unwrap_optional_object_schema(section_ref, full_schema)
@staticmethod
def export_schema(format_type: str = "json") -> str:
@@ -211,6 +267,108 @@ def _extract_options_from_section(
return options
+ @staticmethod
+ def list_all_options_nested(
+ *,
+ merge_model_defaults: bool = True,
+ ) -> list[dict[str, Any]]:
+ """List every leaf config path (nested dotted paths) with metadata.
+
+ Walks top-level ``Config`` JSON Schema properties, resolves ``$ref`` into
+ ``$defs``, and recurses into object-typed sub-schemas. For each scalar /
+ array / enum leaf, emits one row.
+
+ Args:
+ merge_model_defaults: When True, fill ``default`` from a fresh
+ ``Config().model_dump(mode="json")`` when the schema has no static
+ default, and set ``default_source`` to ``schema`` or ``model``.
+
+ Returns:
+ List of option dicts with keys: ``path``, ``section``, ``type``,
+ ``description``, ``default``, ``default_source``, ``required``.
+
+ """
+ schema = ConfigSchema.generate_full_schema()
+ model_defaults: dict[str, Any] = {}
+ if merge_model_defaults:
+ model_defaults = Config().model_dump(mode="json")
+
+ options: list[dict[str, Any]] = []
+
+ def walk_object(
+ path_prefix: str,
+ obj_schema: dict[str, Any],
+ parent_required: frozenset[str],
+ ) -> None:
+ node = _unwrap_optional_object_schema(obj_schema, schema)
+ props = node.get("properties")
+ if not isinstance(props, dict):
+ return
+ req_here = frozenset(node.get("required", []) or [])
+ for key, sub_raw in props.items():
+ path = f"{path_prefix}.{key}" if path_prefix else key
+ sub = _unwrap_optional_object_schema(sub_raw, schema)
+ sub_props = sub.get("properties")
+ if isinstance(sub_props, dict) and sub_props:
+ walk_object(path, sub, req_here)
+ continue
+ # Leaf (primitive, array, enum, union, nested $ref object without props)
+ schema_default = sub.get("default", None)
+ model_default = _get_nested_default(model_defaults, path)
+ if schema_default is not None:
+ default_val: Any = schema_default
+ default_source = "schema"
+ elif model_default is not None:
+ default_val = model_default
+ default_source = "model"
+ else:
+ default_val = None
+ default_source = "model"
+ is_required = key in parent_required
+ options.append(
+ {
+ "path": path,
+ "section": path.split(".", 1)[0] if "." in path else path,
+ "type": _schema_type_label(sub),
+ "description": (sub.get("description") or "")[:2000],
+ "default": default_val,
+ "default_source": default_source,
+ "required": is_required,
+ }
+ )
+
+ root_props = schema.get("properties", {})
+ if not isinstance(root_props, dict):
+ return []
+ top_required = frozenset(schema.get("required", []) or [])
+ for section_name, section_ref in root_props.items():
+ sec = _unwrap_optional_object_schema(section_ref, schema)
+ sec_props = sec.get("properties")
+ if isinstance(sec_props, dict) and sec_props:
+ sec_req = frozenset(sec.get("required", []) or [])
+ walk_object(section_name, sec, sec_req)
+ else:
+ # Unusual: top-level inline non-object
+ schema_default = sec.get("default")
+ model_default = _get_nested_default(model_defaults, section_name)
+ if schema_default is not None:
+ default_val, default_source = schema_default, "schema"
+ else:
+ default_val, default_source = model_default, "model"
+ options.append(
+ {
+ "path": section_name,
+ "section": section_name,
+ "type": _schema_type_label(sec),
+ "description": (sec.get("description") or "")[:2000],
+ "default": default_val,
+ "default_source": default_source,
+ "required": section_name in top_required,
+ }
+ )
+
+ return options
+
@staticmethod
def get_section_options(section_name: str) -> list[dict[str, Any]]:
"""Get all options for a specific configuration section.
diff --git a/ccbt/config/config_templates.py b/ccbt/config/config_templates.py
index b8ee601e..3dd6fe30 100644
--- a/ccbt/config/config_templates.py
+++ b/ccbt/config/config_templates.py
@@ -204,7 +204,7 @@ class ConfigTemplates:
},
"security": {
"enable_encryption": True,
- "encryption_preference": "prefer_encrypted",
+ "encryption_mode": "preferred",
"validate_peers": True,
"peer_validation_timeout": 30,
"rate_limit_enabled": True,
@@ -229,6 +229,19 @@ class ConfigTemplates:
"ssl_protocol_version": "TLSv1.2",
"ssl_cipher_suites": [],
"ssl_allow_insecure_peers": True,
+ "ssl_tracker_pins": {},
+ },
+ "authenticated_swarms": {
+ "mode": "off",
+ "discovery_mode": "trackers_only",
+ "discovery_strict_for_strict_mode": True,
+ "trusted_swarm_ids": [],
+ "strict_ltep_handshake_timeout_s": 30.0,
+ "fail_closed_on_parse_errors": False,
+ "trust_store_path": None,
+ "trust_store_refresh_interval_s": 60.0,
+ "revocation_profile_path": None,
+ "revocation_refresh_interval_s": 300.0,
},
},
"proxy": {
@@ -412,7 +425,7 @@ class ConfigTemplates:
},
"security": {
"enable_encryption": False,
- "encryption_preference": "allow_plaintext",
+ "encryption_mode": "preferred",
"validate_peers": False,
"peer_validation_timeout": 60,
"rate_limit_enabled": True,
@@ -437,6 +450,19 @@ class ConfigTemplates:
"ssl_protocol_version": "TLSv1.2",
"ssl_cipher_suites": [],
"ssl_allow_insecure_peers": True,
+ "ssl_tracker_pins": {},
+ },
+ "authenticated_swarms": {
+ "mode": "off",
+ "discovery_mode": "trackers_only",
+ "discovery_strict_for_strict_mode": True,
+ "trusted_swarm_ids": [],
+ "strict_ltep_handshake_timeout_s": 30.0,
+ "fail_closed_on_parse_errors": False,
+ "trust_store_path": None,
+ "trust_store_refresh_interval_s": 60.0,
+ "revocation_profile_path": None,
+ "revocation_refresh_interval_s": 300.0,
},
},
"proxy": {
@@ -633,7 +659,7 @@ class ConfigTemplates:
},
"security": {
"enable_encryption": True,
- "encryption_preference": "prefer_encrypted",
+ "encryption_mode": "preferred",
"validate_peers": True,
"peer_validation_timeout": 30,
"rate_limit_enabled": True,
@@ -658,6 +684,19 @@ class ConfigTemplates:
"ssl_protocol_version": "TLSv1.2",
"ssl_cipher_suites": [],
"ssl_allow_insecure_peers": True,
+ "ssl_tracker_pins": {},
+ },
+ "authenticated_swarms": {
+ "mode": "off",
+ "discovery_mode": "trackers_only",
+ "discovery_strict_for_strict_mode": True,
+ "trusted_swarm_ids": [],
+ "strict_ltep_handshake_timeout_s": 30.0,
+ "fail_closed_on_parse_errors": False,
+ "trust_store_path": None,
+ "trust_store_refresh_interval_s": 60.0,
+ "revocation_profile_path": None,
+ "revocation_refresh_interval_s": 300.0,
},
},
"ml": {
@@ -845,7 +884,7 @@ class ConfigTemplates:
},
"security": {
"enable_encryption": True,
- "encryption_preference": "prefer_encrypted",
+ "encryption_mode": "preferred",
"validate_peers": True,
"peer_validation_timeout": 30,
"rate_limit_enabled": True,
@@ -870,6 +909,19 @@ class ConfigTemplates:
"ssl_protocol_version": "TLSv1.2",
"ssl_cipher_suites": [],
"ssl_allow_insecure_peers": True,
+ "ssl_tracker_pins": {},
+ },
+ "authenticated_swarms": {
+ "mode": "off",
+ "discovery_mode": "trackers_only",
+ "discovery_strict_for_strict_mode": True,
+ "trusted_swarm_ids": [],
+ "strict_ltep_handshake_timeout_s": 30.0,
+ "fail_closed_on_parse_errors": False,
+ "trust_store_path": None,
+ "trust_store_refresh_interval_s": 60.0,
+ "revocation_profile_path": None,
+ "revocation_refresh_interval_s": 300.0,
},
},
"ml": {
diff --git a/ccbt/config/env_bootstrap.py b/ccbt/config/env_bootstrap.py
new file mode 100644
index 00000000..32d129c5
--- /dev/null
+++ b/ccbt/config/env_bootstrap.py
@@ -0,0 +1,144 @@
+"""Optional .env file loading before configuration reads os.environ.
+
+The process environment is the only source ``ConfigManager`` uses (see
+``ccbt.config.config.ConfigManager._get_env_config``). A ``.env`` file on disk is
+not loaded unless the launcher sets ``CCBT_LOAD_DOTENV=1`` (or ``true``/``yes``/``on``)
+so existing deployments are unchanged.
+
+``CCBT_DOTENV_PATH`` may point to an alternate file; otherwise ``.env`` in the
+current working directory is used. Variables already set in ``os.environ`` are
+not overwritten (standard dotenv behavior).
+"""
+
+from __future__ import annotations
+
+import logging
+import os
+from pathlib import Path
+from typing import Optional
+
+logger = logging.getLogger(__name__)
+
+_LOAD_DOTENV_FLAG = "CCBT_LOAD_DOTENV"
+_DOTENV_PATH_VAR = "CCBT_DOTENV_PATH"
+
+
+def _truthy_env(raw: Optional[str]) -> bool:
+ if raw is None:
+ return False
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
+
+
+def _strip_quotes(value: str) -> str:
+ v = value.strip()
+ if len(v) >= 2 and v[0] == v[-1] and v[0] in "\"'":
+ return v[1:-1]
+ return v
+
+
+def _strip_inline_comment_suffix(text: str) -> str:
+ """Drop trailing comment after whitespace+``#`` (same rule as shell / python-dotenv)."""
+ s = text
+ i = 0
+ while True:
+ idx = s.find("#", i)
+ if idx == -1:
+ return s
+ if idx > 0 and s[idx - 1].isspace():
+ return s[: idx - 1].rstrip()
+ i = idx + 1
+
+
+def _parse_dotenv_value(rest: str) -> str:
+ """Parse the RHS of ``KEY=...``: quotes, then strip trailing `` # comment``."""
+ s = rest.strip()
+ if not s:
+ return ""
+ if s.startswith("#"):
+ return ""
+ q = s[0]
+ if q in "\"'":
+ i = 1
+ while i < len(s):
+ if s[i] == "\\" and i + 1 < len(s):
+ i += 2
+ continue
+ if s[i] == q:
+ inner = s[1:i]
+ tail = s[i + 1 :].strip()
+ if not tail or tail.startswith("#"):
+ return inner
+ return inner
+ i += 1
+ # Unclosed quote: best-effort like unquoted
+ return _strip_quotes(_strip_inline_comment_suffix(s))
+
+
+def _parse_dotenv_line(line: str) -> Optional[tuple[str, str]]:
+ s = line.strip()
+ if not s or s.startswith("#"):
+ return None
+ if s.lower().startswith("export "):
+ s = s[7:].lstrip()
+ if "=" not in s:
+ return None
+ key, _, rest = s.partition("=")
+ key = key.strip()
+ if not key or not all(c.isalnum() or c == "_" for c in key):
+ return None
+ return key, _parse_dotenv_value(rest)
+
+
+def load_dotenv_file(path: Path) -> int:
+ """Load KEY=VALUE pairs from path into os.environ if keys are unset.
+
+ Returns:
+ Number of variables set (not counting skipped existing keys).
+
+ """
+ if not path.is_file():
+ logger.debug("Dotenv file not found or not a file: %s", path)
+ return 0
+
+ count = 0
+ try:
+ text = path.read_text(encoding="utf-8")
+ except OSError as e:
+ logger.debug("Could not read dotenv file %s: %s", path, e)
+ return 0
+
+ for line in text.splitlines():
+ parsed = _parse_dotenv_line(line)
+ if parsed is None:
+ continue
+ key, value = parsed
+ if key in os.environ:
+ continue
+ os.environ[key] = value
+ count += 1
+ return count
+
+
+def maybe_load_dotenv_from_env() -> None:
+ """If ``CCBT_LOAD_DOTENV`` is truthy, merge ``.env`` into the process environment."""
+ raw_flag = os.getenv(_LOAD_DOTENV_FLAG)
+ if not _truthy_env(raw_flag):
+ os.environ.setdefault("CCBT_DOTENV_LOADED", "0")
+ logger.debug(
+ "Skipping dotenv load: %s is not truthy (value=%r)",
+ _LOAD_DOTENV_FLAG,
+ raw_flag,
+ )
+ return
+
+ raw_path = os.getenv(_DOTENV_PATH_VAR)
+ path = Path(raw_path).expanduser() if raw_path else Path.cwd() / ".env"
+ n = load_dotenv_file(path)
+ os.environ["CCBT_DOTENV_LOADED"] = "1"
+ os.environ["CCBT_DOTENV_PATH_EFFECTIVE"] = str(path)
+ os.environ["CCBT_DOTENV_KEYS_LOADED"] = str(int(n))
+ logger.debug(
+ "Loaded %d variable(s) from dotenv (path=%s, CCBT_LOAD_DOTENV set)",
+ n,
+ path,
+ )
diff --git a/ccbt/consensus/raft.py b/ccbt/consensus/raft.py
index 53ca7324..9b201b73 100644
--- a/ccbt/consensus/raft.py
+++ b/ccbt/consensus/raft.py
@@ -341,7 +341,7 @@ async def _election_loop(self) -> None:
await asyncio.sleep(0.1)
else:
- # CRITICAL FIX: Add sleep when election condition is false to prevent busy-waiting
+ # Note: Add sleep when election condition is false to prevent busy-waiting
await asyncio.sleep(0.1)
except asyncio.CancelledError:
diff --git a/ccbt/core/magnet.py b/ccbt/core/magnet.py
index 73b2d081..193b7369 100644
--- a/ccbt/core/magnet.py
+++ b/ccbt/core/magnet.py
@@ -19,6 +19,7 @@ class MagnetInfo:
info_hash: bytes
display_name: Optional[str]
+ swarm_id: Optional[str]
trackers: list[str]
web_seeds: list[str]
selected_indices: Optional[list[int]] = None # BEP 53: so parameter
@@ -234,6 +235,7 @@ def parse_magnet(uri: str) -> MagnetInfo:
return MagnetInfo(
info_hash=info_hash,
display_name=display_name,
+ swarm_id=qs.get("swarm_id", [None])[0],
trackers=trackers,
web_seeds=web_seeds,
selected_indices=selected_indices,
@@ -246,20 +248,21 @@ def build_minimal_torrent_data(
name: Optional[str],
trackers: list[str],
web_seeds: Optional[list[str]] = None,
+ swarm_id: Optional[str] = None,
) -> dict[str, Any]:
"""Create a minimal `torrent_data` placeholder using known info.
This structure is suitable for tracker/DHT peer discovery and metadata
fetching, but lacks `info` details and piece layout until metadata is fetched.
- CRITICAL FIX: If no trackers are provided, add default public trackers to enable
+ Note: If no trackers are provided, add default public trackers to enable
peer discovery. This is essential for magnet links that only have web seeds (ws=)
but no trackers (tr=).
- CRITICAL FIX: Store web seeds (ws= parameters) from magnet links so they can be
+ Note: Store web seeds (ws= parameters) from magnet links so they can be
used by the WebSeedExtension for downloading pieces via HTTP range requests.
"""
- # CRITICAL FIX: Add default trackers if none provided
+ # Note: Add default trackers if none provided
# This enables peer discovery for magnet links without tr= parameters
# However, respect explicit empty list when passed (for testing/edge cases)
# The function signature requires a list, so we can't distinguish None from []
@@ -355,8 +358,10 @@ def build_minimal_torrent_data(
"name": name or "",
"is_magnet": True, # CRITICAL: Mark as magnet link for DHT setup to prioritize DHT queries
}
+ if swarm_id:
+ result["swarm_id"] = swarm_id
- # CRITICAL FIX: Store web seeds from magnet link (ws= parameters)
+ # Note: Store web seeds from magnet link (ws= parameters)
# These will be used by WebSeedExtension to download pieces via HTTP range requests
if web_seeds:
result["web_seeds"] = web_seeds
@@ -405,6 +410,7 @@ def magnet_info_from_minimal_torrent_data(
return MagnetInfo(
info_hash=info_hash,
display_name=name,
+ swarm_id=torrent_data.get("swarm_id"),
trackers=trackers,
web_seeds=web_seeds,
selected_indices=None,
@@ -478,7 +484,7 @@ def build_torrent_data_from_metadata( # pragma: no cover - BEP 9 (not BEP 53),
# Extract piece hashes
piece_length = int(info_dict.get(b"piece length", 0))
- # CRITICAL FIX: Handle both bytes and string keys for 'pieces' field
+ # Note: Handle both bytes and string keys for 'pieces' field
# Some decoders may return string keys instead of bytes
pieces_blob = b""
if b"pieces" in info_dict:
@@ -513,7 +519,7 @@ def build_torrent_data_from_metadata( # pragma: no cover - BEP 9 (not BEP 53),
piece_hashes = [pieces_blob[i : i + 20] for i in range(0, len(pieces_blob), 20)]
- # CRITICAL FIX: Log piece hash extraction for debugging
+ # Note: Log piece hash extraction for debugging
import logging
logger = logging.getLogger(__name__)
@@ -585,7 +591,7 @@ def build_torrent_data_from_metadata( # pragma: no cover - BEP 9 (not BEP 53),
)
)
- # CRITICAL FIX: Validate piece count matches expected count based on total_length
+ # Note: Validate piece count matches expected count based on total_length
# Expected piece count = ceil(total_length / piece_length)
import logging
import math
diff --git a/ccbt/core/tonic.py b/ccbt/core/tonic.py
index 71abe4fc..1e2d262f 100644
--- a/ccbt/core/tonic.py
+++ b/ccbt/core/tonic.py
@@ -271,7 +271,7 @@ def get_file_tree(self, tonic_data: dict[str, Any]) -> dict[str, Any]:
"""
info = tonic_data.get("info", {})
- # CRITICAL FIX: Check for both "file_tree" (from _extract_tonic_data) and "file tree" (from raw bencoded)
+ # Note: Check for both "file_tree" (from _extract_tonic_data) and "file tree" (from raw bencoded)
# Also check for bytes keys for backward compatibility
file_tree = (
info.get("file_tree") # From _extract_tonic_data (parsed format)
diff --git a/ccbt/daemon/daemon_manager.py b/ccbt/daemon/daemon_manager.py
index 3e2c7183..576182fd 100644
--- a/ccbt/daemon/daemon_manager.py
+++ b/ccbt/daemon/daemon_manager.py
@@ -29,7 +29,7 @@
def _get_daemon_home_dir() -> Path:
"""Get daemon home directory with consistent path resolution.
- CRITICAL FIX: Use multiple methods to ensure consistent path resolution on Windows,
+ Note: Use multiple methods to ensure consistent path resolution on Windows,
especially with spaces in usernames. Normalize the path to handle case/space differences.
Returns:
@@ -126,7 +126,7 @@ def __init__(
"""
if state_dir is None:
- # CRITICAL FIX: Use consistent path resolution helper
+ # Note: Use consistent path resolution helper
home_dir = _get_daemon_home_dir()
state_dir = home_dir / ".ccbt" / "daemon"
logger.debug(
@@ -159,7 +159,7 @@ def ensure_single_instance(self) -> bool:
"""
if self.pid_file.exists():
try:
- # CRITICAL FIX: Read with retry and validation to handle race conditions
+ # Note: Read with retry and validation to handle race conditions
# Multiple CLI commands might read simultaneously
pid_text = None
for attempt in range(3):
@@ -239,7 +239,7 @@ def get_pid(self) -> Optional[int]:
return None
try:
- # CRITICAL FIX: Read with retry to handle race conditions
+ # Note: Read with retry to handle race conditions
pid_text = None
for attempt in range(3):
try:
@@ -332,7 +332,7 @@ def acquire_lock(self) -> bool:
if sys.platform == "win32":
# Windows: use exclusive file creation
- # CRITICAL FIX: First check if lock file exists and if process is running
+ # Note: First check if lock file exists and if process is running
# This handles stale locks from crashed processes
if self.lock_file.exists():
try:
@@ -411,7 +411,7 @@ def acquire_lock(self) -> bool:
with contextlib.suppress(OSError, PermissionError):
self.lock_file.unlink() # Ignore - will try to create new lock
- # CRITICAL FIX: Use atomic lock file creation with retry logic
+ # Note: Use atomic lock file creation with retry logic
# On Windows, file creation is atomic, but we need to handle race conditions
# where multiple processes try to remove stale locks simultaneously
max_retries = 3
@@ -594,13 +594,13 @@ def write_pid(self, acquire_lock: bool = True) -> None:
"""
pid = os.getpid()
- # CRITICAL FIX: Acquire lock before writing PID file (if not already acquired)
+ # Note: Acquire lock before writing PID file (if not already acquired)
# This ensures atomic daemon detection
if acquire_lock and not self.acquire_lock():
msg = "Cannot acquire daemon lock file. Another daemon may be starting."
raise RuntimeError(msg)
- # CRITICAL FIX: Use atomic write to prevent corruption
+ # Note: Use atomic write to prevent corruption
# Write to temp file first, then rename atomically
# This ensures PID file is never in a corrupted state
temp_file = self.pid_file.with_suffix(self.pid_file.suffix + ".tmp")
@@ -676,7 +676,7 @@ def start(
# Start process
try:
- # CRITICAL FIX: Capture stderr to a log file for background mode
+ # Note: Capture stderr to a log file for background mode
# This allows debugging daemon startup failures
log_file = self.state_dir / "daemon_startup.log"
log_fd: Union[int, Any] = subprocess.DEVNULL
@@ -694,7 +694,7 @@ def start(
start_new_session=True,
)
- # CRITICAL FIX: Wait longer and check multiple times
+ # Note: Wait longer and check multiple times
# This gives the daemon time to initialize and write PID file
# Increased to 60.0s to account for slow startup (NAT discovery can take ~35s, DHT bootstrap ~8s, etc.)
max_wait_time = 60.0 # Maximum time to wait (NAT discovery + DHT bootstrap + IPC server startup)
@@ -847,7 +847,7 @@ def setup_signal_handlers(self, shutdown_callback: Any) -> None:
# Store reference to shutdown callback for direct access
self._shutdown_callback = shutdown_callback
- # CRITICAL FIX: Extract daemon instance and shutdown event from callback
+ # Note: Extract daemon instance and shutdown event from callback
# This allows us to set the event synchronously in signal handler
daemon_instance = None
shutdown_event = None
@@ -862,7 +862,7 @@ def setup_signal_handlers(self, shutdown_callback: Any) -> None:
def signal_handler(signum: int, _frame: Any) -> None:
"""Handle shutdown signal."""
- # CRITICAL FIX: Prevent multiple signal handler calls
+ # Note: Prevent multiple signal handler calls
# Check if shutdown is already in progress
if shutdown_event is not None and shutdown_event.is_set():
logger.debug(
@@ -874,7 +874,7 @@ def signal_handler(signum: int, _frame: Any) -> None:
logger.info("Received signal %d, initiating shutdown", signum)
self._shutdown_requested = True
- # CRITICAL FIX: Set global shutdown flag early to suppress verbose logging
+ # Note: Set global shutdown flag early to suppress verbose logging
try:
from ccbt.utils.shutdown import set_shutdown
@@ -901,7 +901,7 @@ def signal_handler(signum: int, _frame: Any) -> None:
"Error scheduling checkpoint save from signal handler: %s", e
)
- # CRITICAL FIX: Set shutdown event synchronously FIRST
+ # Note: Set shutdown event synchronously FIRST
# This ensures shutdown happens even if task creation fails
# asyncio.Event.set() is thread-safe and works immediately
if shutdown_event is not None:
diff --git a/ccbt/daemon/ipc_client.py b/ccbt/daemon/ipc_client.py
index b4d2456d..8f78b1d7 100644
--- a/ccbt/daemon/ipc_client.py
+++ b/ccbt/daemon/ipc_client.py
@@ -70,6 +70,7 @@
TorrentListResponse,
TorrentStatusResponse,
TrackerListResponse,
+ UISnapshotResponse,
WebSocketEvent,
WebSocketMessage,
WebSocketSubscribeRequest,
@@ -191,7 +192,7 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
RuntimeError: If event loop is closed or not in async context
"""
- # CRITICAL FIX: Verify we're in an async context with a running event loop
+ # Note: Verify we're in an async context with a running event loop
try:
current_loop = asyncio.get_running_loop()
if current_loop.is_closed():
@@ -213,7 +214,7 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
raise RuntimeError(msg) from e
raise
- # CRITICAL FIX: Recreate session if it's bound to a different or closed loop
+ # Note: Recreate session if it's bound to a different or closed loop
# aiohttp.ClientSession binds to the event loop when created. If the session was
# created in a different loop (e.g., a previous asyncio.run() call), it cannot be
# used in the current loop even if the old loop is closed.
@@ -230,7 +231,7 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
if self._session and not self.session.closed:
try:
await self.session.close()
- # CRITICAL FIX: On Windows, wait longer for session cleanup to prevent socket buffer exhaustion
+ # Note: On Windows, wait longer for session cleanup to prevent socket buffer exhaustion
import sys
if sys.platform == "win32":
@@ -245,7 +246,7 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
except Exception:
pass
except Exception as e:
- # CRITICAL FIX: Handle WinError 10055 gracefully
+ # Note: Handle WinError 10055 gracefully
import sys
error_code = getattr(e, "winerror", None) or getattr(
@@ -258,10 +259,10 @@ async def _ensure_session(self) -> aiohttp.ClientSession:
else:
logger.debug("Error closing session: %s", e)
- # CRITICAL FIX: Create session in the current running loop context
+ # Note: Create session in the current running loop context
# aiohttp.ClientSession will automatically use the current running loop
# In aiohttp 3.x+, we don't pass loop parameter (it's deprecated)
- # CRITICAL FIX: Add connection limits to prevent Windows socket buffer exhaustion (WinError 10055)
+ # Note: Add connection limits to prevent Windows socket buffer exhaustion (WinError 10055)
# Windows has limited socket buffer space, so we need to limit concurrent connections
import sys
@@ -382,7 +383,7 @@ async def close(self) -> None:
) # Increased wait time on Windows
await asyncio.sleep(wait_time)
- # CRITICAL FIX: On Windows, also close the connector to ensure all sockets are released
+ # Note: On Windows, also close the connector to ensure all sockets are released
if sys.platform == "win32" and hasattr(self._session, "connector"):
connector = self.session.connector
if connector and not connector.closed:
@@ -396,7 +397,7 @@ async def close(self) -> None:
except Exception as e:
logger.debug(_("Error closing HTTP session: %s"), e)
finally:
- # CRITICAL FIX: On Windows, ensure connector is also closed to release all sockets
+ # Note: On Windows, ensure connector is also closed to release all sockets
import sys
if (
@@ -496,7 +497,7 @@ async def add_torrent(
daemon_error_msg = f"Daemon error when adding torrent: {error_msg}"
raise RuntimeError(daemon_error_msg) from e
except RuntimeError as e:
- # CRITICAL FIX: Catch "Event loop is closed" errors specifically
+ # Note: Catch "Event loop is closed" errors specifically
if "event loop is closed" in str(e).lower():
logger.exception(
"Event loop is closed when adding torrent to daemon at %s. "
@@ -1072,7 +1073,7 @@ async def shutdown(self) -> bool:
"""Request daemon shutdown.
Returns:
- True if shutdown request was sent
+ True if shutdown request was accepted
"""
session = await self._ensure_session()
@@ -1081,7 +1082,18 @@ async def shutdown(self) -> bool:
try:
async with session.post(url, headers=self._get_headers()) as resp:
resp.raise_for_status()
- return True
+ payload = await resp.json()
+ if not isinstance(payload, dict):
+ return False
+ accepted = payload.get("accepted")
+ if isinstance(accepted, bool):
+ return accepted
+ # Backward compatibility with older daemon payloads.
+ status = payload.get("status")
+ return isinstance(status, str) and status in {
+ "shutting_down",
+ "already_shutting_down",
+ }
except Exception as e:
logger.debug(_("Error sending shutdown request: %s"), e)
return False
@@ -2208,6 +2220,15 @@ async def get_global_stats(self) -> GlobalStatsResponse:
data = await resp.json()
return GlobalStatsResponse(**data)
+ async def get_ui_snapshot(self) -> UISnapshotResponse:
+ """Get dashboard first-paint snapshot (global stats, torrents, services, rate samples).
+
+ Returns:
+ UISnapshotResponse with global_stats, torrents, services_status, rate_samples.
+ """
+ data = await self._get_json("/ui/snapshot")
+ return UISnapshotResponse(**data)
+
async def global_pause_all(self) -> dict[str, Any]:
"""Pause all torrents.
@@ -2888,7 +2909,7 @@ async def is_daemon_running(self) -> bool:
port = parsed.port or DEFAULT_IPC_PORT
# Quick socket test to verify port is open
- # CRITICAL FIX: On Windows, error 10035 (WSAEWOULDBLOCK) can be a false positive
+ # Note: On Windows, error 10035 (WSAEWOULDBLOCK) can be a false positive
# Skip socket test if we get this error and proceed to HTTP check
import sys
@@ -3012,7 +3033,7 @@ def get_daemon_pid() -> Optional[int]:
PID or None if not found or invalid
"""
- # CRITICAL FIX: Use consistent path resolution helper to match daemon
+ # Note: Use consistent path resolution helper to match daemon
from ccbt.daemon.daemon_manager import _get_daemon_home_dir
home_dir = _get_daemon_home_dir()
@@ -3029,7 +3050,7 @@ def get_daemon_pid() -> Optional[int]:
return None
try:
- # CRITICAL FIX: Read with retry to handle race conditions
+ # Note: Read with retry to handle race conditions
import time
pid_text = None
diff --git a/ccbt/daemon/ipc_protocol.py b/ccbt/daemon/ipc_protocol.py
index bcd552ba..76c4b405 100644
--- a/ccbt/daemon/ipc_protocol.py
+++ b/ccbt/daemon/ipc_protocol.py
@@ -46,6 +46,7 @@ class EventType(str, Enum):
PEER_DISCONNECTED = "peer_disconnected"
PEER_HANDSHAKE_COMPLETE = "peer_handshake_complete"
PEER_BITFIELD_RECEIVED = "peer_bitfield_received"
+ PEER_QUALITY_RANKED = "peer_quality_ranked"
# Seeding events
SEEDING_STARTED = "seeding_started"
SEEDING_STOPPED = "seeding_stopped"
@@ -93,6 +94,13 @@ class StatusResponse(BaseModel):
version: str = Field(..., description="Daemon version")
num_torrents: int = Field(0, description="Number of active torrents")
ipc_url: str = Field(..., description="IPC server URL")
+ inbound_unknown_info_hash_metrics_top: dict[str, int] = Field(
+ default_factory=dict,
+ description=(
+ "Top unknown inbound info-hash prefixes (16-char hex) by count across TCP listeners; "
+ "empty when none. See GET /api/v1/status and network troubleshooting docs."
+ ),
+ )
class XetSyncModeRequest(BaseModel):
@@ -136,6 +144,14 @@ class XetDiscoveryBackendStatus(BaseModel):
None,
description="Timestamp of last successful backend operation",
)
+ udp_tracker_client_ready: Optional[bool] = Field(
+ None,
+ description="UDP BEP-15 client running (tracker backend only)",
+ )
+ udp_tracker_client_init_failed: Optional[bool] = Field(
+ None,
+ description="UDP tracker client failed to start (tracker backend only)",
+ )
class XetDiscoveryStatusResponse(BaseModel):
@@ -217,6 +233,27 @@ class TorrentStatusResponse(BaseModel):
)
pieces_completed: int = Field(0, description="Number of completed pieces")
pieces_total: int = Field(0, description="Total number of pieces")
+ tracker_status: Optional[str] = Field(None, description="Tracker health state")
+ last_tracker_error: Optional[str] = Field(
+ None, description="Last tracker-specific error"
+ )
+ last_error: Optional[str] = Field(None, description="Last torrent/session error")
+ productive_peers: int = Field(0, description="Peers currently making progress")
+ requestable_peers: int = Field(
+ 0, description="Peers currently eligible for requests"
+ )
+ handshake_complete_peers: int = Field(
+ 0, description="Peers that completed the base BitTorrent handshake"
+ )
+ extension_capable_peers: int = Field(
+ 0, description="Peers that advertised BEP 10 extension support"
+ )
+ metadata_capable_peers: int = Field(
+ 0, description="Peers that advertised ut_metadata support"
+ )
+ hash_verification_failures: int = Field(
+ 0, description="Pieces rejected after hash verification"
+ )
class TorrentListResponse(BaseModel):
@@ -778,6 +815,28 @@ class GlobalStatsResponse(BaseModel):
)
+# UI Snapshot (first-paint hydration)
+class UISnapshotResponse(BaseModel):
+ """Single response for dashboard first-paint: global stats, torrent list, services, and minimal rate history."""
+
+ global_stats: dict[str, Any] = Field(
+ default_factory=dict,
+ description="Global session statistics (same shape as session/stats)",
+ )
+ torrents: list[dict[str, Any]] = Field(
+ default_factory=list,
+ description="List of torrent status dicts (same shape as GET /torrents)",
+ )
+ services_status: dict[str, Any] = Field(
+ default_factory=dict,
+ description="Coarse status of dht, nat, tcp_server, peer_service, ipc_server",
+ )
+ rate_samples: list[dict[str, Any]] = Field(
+ default_factory=list,
+ description="Recent rate samples for graph (timestamp, download_rate, upload_rate); may be truncated",
+ )
+
+
# Protocol Models
class ProtocolInfo(BaseModel):
"""Protocol information."""
@@ -1003,6 +1062,39 @@ class DetailedGlobalMetricsResponse(BaseModel):
total_bytes_uploaded: int = Field(
0, description="Total bytes uploaded to all peers"
)
+ swarm_auth_gate_total: int = Field(
+ 0, description="Total swarm-auth gate evaluations"
+ )
+ swarm_auth_gate_by_mode_strict_total: int = Field(
+ 0, description="Swarm-auth gate decisions in strict mode"
+ )
+ swarm_auth_gate_by_mode_opportunistic_total: int = Field(
+ 0, description="Swarm-auth gate decisions in opportunistic mode"
+ )
+ swarm_auth_gate_by_mode_off_total: int = Field(
+ 0, description="Swarm-auth gate decisions when mode is off"
+ )
+ swarm_auth_gate_allow_total: int = Field(0, description="Swarm-auth allows")
+ swarm_auth_gate_deny_total: int = Field(0, description="Swarm-auth denies")
+ swarm_auth_gate_reason_invalid_signature_total: int = Field(
+ 0, description="Swarm-auth invalid signature denies"
+ )
+ swarm_auth_opportunistic_verify_failed_total: int = Field(
+ 0,
+ description="Swarm-auth opportunistic verification failures in non-blocking mode",
+ )
+ swarm_auth_strict_ltep_timeout_total: int = Field(
+ 0, description="Swarm-auth strict LTEP timeout rejections"
+ )
+ swarm_auth_truststore_reload_total: int = Field(
+ 0, description="Swarm-auth truststore reload events"
+ )
+ swarm_auth_revocation_hits_total: int = Field(
+ 0, description="Swarm-auth revocation hits"
+ )
+ swarm_auth_discovery_suppressed_total: int = Field(
+ 0, description="Swarm-auth discovery suppression events"
+ )
peer_efficiency_distribution: dict[str, int] = Field(
default_factory=dict,
description="Distribution of peer efficiency (tier -> count)",
@@ -1059,6 +1151,58 @@ class DHTQueryMetricsResponse(BaseModel):
last_query_depth: int = Field(0, description="Query depth of last query")
last_query_nodes_queried: int = Field(0, description="Nodes queried in last query")
routing_table_size: int = Field(0, description="Current DHT routing table size")
+ bootstrap_success_count: int = Field(
+ 0, description="Number of successful bootstrap or rebootstrap attempts"
+ )
+ bootstrap_failure_count: int = Field(
+ 0, description="Number of failed bootstrap or rebootstrap attempts"
+ )
+ bootstrap_recovery_attempts: int = Field(
+ 0,
+ description="Number of bootstrap recovery attempts (including rebootstrap and fallback)",
+ )
+ bootstrap_health_state: str = Field(
+ "unknown", description="Current bootstrap health state"
+ )
+ bootstrap_zero_state_count: int = Field(
+ 0,
+ description="Count of times bootstrap completed with zero routing-table nodes",
+ )
+ bootstrap_zero_nodes_last_reason: str = Field(
+ "", description="Reason from the last zero-node bootstrap outcome"
+ )
+ rebootstrap_attempt_count: int = Field(
+ 0, description="Number of rebootstrap attempts"
+ )
+ rebootstrap_success_count: int = Field(
+ 0, description="Number of successful rebootstrap attempts"
+ )
+ rebootstrap_failure_count: int = Field(
+ 0, description="Number of failed rebootstrap attempts"
+ )
+ rebootstrap_last_outcome: str = Field(
+ "not_attempted", description="Last rebootstrap attempt outcome"
+ )
+ rebootstrap_last_reason: str = Field(
+ "", description="Reason label for last rebootstrap attempt"
+ )
+ rebootstrap_last_source: str = Field("", description="Source of last rebootstrap")
+ rebootstrap_health_state: str = Field(
+ "unknown", description="Current rebootstrap health state"
+ )
+ rebootstrap_consecutive_failures: int = Field(
+ 0, description="Consecutive rebootstrap failures"
+ )
+ last_bootstrap_reason: str = Field(
+ "", description="Reason label for the last bootstrap attempt"
+ )
+ last_bootstrap_failure_reason: str = Field(
+ "", description="Last recorded bootstrap failure reason"
+ )
+ last_zero_node_lookup_at: float = Field(
+ 0.0,
+ description="Timestamp of the last lookup that queried zero nodes",
+ )
class PeerQualityMetricsResponse(BaseModel):
diff --git a/ccbt/daemon/ipc_server.py b/ccbt/daemon/ipc_server.py
index b5a1895f..bdb47a2e 100644
--- a/ccbt/daemon/ipc_server.py
+++ b/ccbt/daemon/ipc_server.py
@@ -13,7 +13,7 @@
import os
import ssl
import time
-from typing import TYPE_CHECKING, Any, Optional
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
import aiohttp
from aiohttp import web
@@ -80,6 +80,7 @@
TrackerAddRequest,
TrackerInfo,
TrackerListResponse,
+ UISnapshotResponse,
WebSocketEvent,
WebSocketMessage,
WebSocketSubscribeRequest,
@@ -109,6 +110,8 @@ def __init__(
websocket_enabled: bool = True,
websocket_heartbeat_interval: float = 30.0,
tls_enabled: bool = False,
+ shutdown_callback: Optional[Callable[[], Awaitable[None]]] = None,
+ shutdown_event: Optional[asyncio.Event] = None,
):
"""Initialize IPC server.
@@ -121,10 +124,12 @@ def __init__(
websocket_enabled: Enable WebSocket support
websocket_heartbeat_interval: WebSocket heartbeat interval in seconds
tls_enabled: Enable TLS/HTTPS (requires key_manager)
+ shutdown_callback: Optional callback invoked when /shutdown is requested
+ shutdown_event: Optional daemon shutdown event (used for idempotent status)
"""
self.session_manager = session_manager
- # CRITICAL FIX: Use ExecutorManager to get executor
+ # Note: Use ExecutorManager to get executor
# This ensures we use the same executor instance initialized at daemon startup
# which has access to all initialized components (UDP tracker, DHT, etc.)
# ExecutorManager ensures single executor instance per session manager
@@ -147,7 +152,7 @@ def __init__(
error_msg = f"Failed to get executor: {e}"
raise RuntimeError(error_msg) from e
- # CRITICAL FIX: Verify executor is ready
+ # Note: Verify executor is ready
# The executor should have access to session_manager and all required components
if not hasattr(self.executor, "adapter") or self.executor.adapter is None:
error_msg = "Executor adapter not initialized"
@@ -168,6 +173,8 @@ def __init__(
self.port = port
self.websocket_enabled = websocket_enabled
self.websocket_heartbeat_interval = websocket_heartbeat_interval
+ self._shutdown_callback = shutdown_callback
+ self._shutdown_event = shutdown_event
self.app = web.Application() # type: ignore[attr-defined]
self.runner: Optional[web.AppRunner] = None # type: ignore[attr-defined]
@@ -699,6 +706,11 @@ def _setup_routes(self) -> None:
self.app.router.add_get(
f"{API_BASE_PATH}/session/stats", self._handle_get_global_stats
)
+ # UI snapshot (first-paint hydration: global stats + torrents + services + rate samples)
+ self.app.router.add_get(
+ f"{API_BASE_PATH}/ui/snapshot",
+ self._handle_ui_snapshot,
+ )
self.app.router.add_post(
f"{API_BASE_PATH}/global/pause-all",
self._handle_global_pause_all,
@@ -813,6 +825,24 @@ async def _handle_status(self, _request: Request) -> Response:
# Get global stats
global_stats = await self.session_manager.get_global_stats()
+ inbound_top: dict[str, int] = {}
+ try:
+ raw_unknown = (
+ await self.session_manager.get_inbound_unknown_info_hash_metrics()
+ )
+ if isinstance(raw_unknown, dict) and raw_unknown:
+ top_n = 32
+ sorted_items = sorted(
+ raw_unknown.items(),
+ key=lambda kv: (-int(kv[1]), str(kv[0])),
+ )[:top_n]
+ inbound_top = {str(k): int(v) for k, v in sorted_items}
+ except Exception:
+ logger.debug(
+ "status: inbound unknown info-hash metrics unavailable",
+ exc_info=True,
+ )
+
status = StatusResponse(
status="running",
pid=pid,
@@ -820,6 +850,7 @@ async def _handle_status(self, _request: Request) -> Response:
version=self._get_package_version(),
num_torrents=global_stats.get("num_torrents", 0),
ipc_url=f"http://{self.host}:{self.port}",
+ inbound_unknown_info_hash_metrics_top=inbound_top,
)
return web.json_response(status.model_dump()) # type: ignore[attr-defined]
@@ -865,7 +896,7 @@ async def _handle_rate_samples(self, request: Request) -> Response:
seconds = 120
try:
- # CRITICAL FIX: session_manager.get_rate_samples() returns list[dict[str, float]]
+ # Note: session_manager.get_rate_samples() returns list[dict[str, float]]
# but RateSamplesResponse expects list[RateSample]
samples_dict = await self.session_manager.get_rate_samples(seconds)
logger.debug(
@@ -1097,10 +1128,8 @@ async def _handle_per_torrent_performance(self, request: Request) -> Response:
progress=status.get("progress", 0.0),
pieces_completed=status.get("pieces_completed", 0),
pieces_total=status.get("pieces_total", 0),
- connected_peers=status.get(
- "connected_peers", status.get("num_peers", 0)
- ),
- active_peers=status.get("active_peers", status.get("num_seeds", 0)),
+ connected_peers=int(status.get("connected_peers", 0)),
+ active_peers=int(status.get("active_peers", 0)),
top_peers=top_peers,
bytes_downloaded=status.get("downloaded", 0),
bytes_uploaded=status.get("uploaded", 0),
@@ -1436,12 +1465,8 @@ async def _handle_detailed_torrent_metrics(self, request: Request) -> Response:
"pieces_completed": status.get("pieces_completed", 0),
"pieces_total": status.get("pieces_total", 0),
"progress": status.get("progress", 0.0),
- "connected_peers": status.get(
- "connected_peers", status.get("num_peers", 0)
- ),
- "active_peers": status.get(
- "active_peers", status.get("num_seeds", 0)
- ),
+ "connected_peers": int(status.get("connected_peers", 0)),
+ "active_peers": int(status.get("active_peers", 0)),
}
# Add enhanced metrics if available
@@ -1616,6 +1641,23 @@ async def _handle_dht_query_metrics(self, request: Request) -> Response:
"last_query_depth": 0,
"last_query_nodes_queried": 0,
"routing_table_size": 0,
+ "bootstrap_success_count": 0,
+ "bootstrap_failure_count": 0,
+ "bootstrap_recovery_attempts": 0,
+ "bootstrap_health_state": "unknown",
+ "bootstrap_zero_state_count": 0,
+ "bootstrap_zero_nodes_last_reason": "",
+ "rebootstrap_attempt_count": 0,
+ "rebootstrap_success_count": 0,
+ "rebootstrap_failure_count": 0,
+ "rebootstrap_last_outcome": "not_attempted",
+ "rebootstrap_last_reason": "",
+ "rebootstrap_last_source": "",
+ "rebootstrap_health_state": "unknown",
+ "rebootstrap_consecutive_failures": 0,
+ "last_bootstrap_reason": "",
+ "last_bootstrap_failure_reason": "",
+ "last_zero_node_lookup_at": 0.0,
}
# Use actual metrics if available
@@ -1656,6 +1698,60 @@ async def _handle_dht_query_metrics(self, request: Request) -> Response:
metrics["last_query_nodes_queried"] = int(
last_query.get("nodes_queried", 0) or 0
) # type: ignore[arg-type]
+ metrics["bootstrap_success_count"] = int(
+ dht_metrics.get("bootstrap_success_count", 0) or 0
+ )
+ metrics["bootstrap_failure_count"] = int(
+ dht_metrics.get("bootstrap_failure_count", 0) or 0
+ )
+ metrics["bootstrap_recovery_attempts"] = int(
+ dht_metrics.get("bootstrap_recovery_attempts", 0) or 0
+ )
+ metrics["bootstrap_health_state"] = str(
+ dht_metrics.get("bootstrap_health_state", "unknown")
+ or "unknown"
+ )
+ metrics["bootstrap_zero_state_count"] = int(
+ dht_metrics.get("bootstrap_zero_state_count", 0) or 0
+ )
+ metrics["bootstrap_zero_nodes_last_reason"] = str(
+ dht_metrics.get("bootstrap_zero_nodes_last_reason", "") or ""
+ )
+ metrics["rebootstrap_attempt_count"] = int(
+ dht_metrics.get("rebootstrap_attempt_count", 0) or 0
+ )
+ metrics["rebootstrap_success_count"] = int(
+ dht_metrics.get("rebootstrap_success_count", 0) or 0
+ )
+ metrics["rebootstrap_failure_count"] = int(
+ dht_metrics.get("rebootstrap_failure_count", 0) or 0
+ )
+ metrics["rebootstrap_last_outcome"] = str(
+ dht_metrics.get("rebootstrap_last_outcome", "not_attempted")
+ or "not_attempted"
+ )
+ metrics["rebootstrap_last_reason"] = str(
+ dht_metrics.get("rebootstrap_last_reason", "") or ""
+ )
+ metrics["rebootstrap_last_source"] = str(
+ dht_metrics.get("rebootstrap_last_source", "") or ""
+ )
+ metrics["rebootstrap_health_state"] = str(
+ dht_metrics.get("rebootstrap_health_state", "unknown")
+ or "unknown"
+ )
+ metrics["rebootstrap_consecutive_failures"] = int(
+ dht_metrics.get("rebootstrap_consecutive_failures", 0) or 0
+ )
+ metrics["last_bootstrap_reason"] = str(
+ dht_metrics.get("last_bootstrap_reason", "") or ""
+ )
+ metrics["last_bootstrap_failure_reason"] = str(
+ dht_metrics.get("last_bootstrap_failure_reason", "") or ""
+ )
+ metrics["last_zero_node_lookup_at"] = float(
+ dht_metrics.get("last_zero_node_lookup_at", 0.0) or 0.0
+ )
# Get routing table size from DHT client
if dht_client and hasattr(dht_client, "routing_table"):
@@ -1704,6 +1800,58 @@ async def _handle_dht_query_metrics(self, request: Request) -> Response:
"routing_table_size": int(
metrics.get("routing_table_size", 0) or 0
),
+ "bootstrap_success_count": int(
+ metrics.get("bootstrap_success_count", 0) or 0
+ ),
+ "bootstrap_failure_count": int(
+ metrics.get("bootstrap_failure_count", 0) or 0
+ ),
+ "bootstrap_recovery_attempts": int(
+ metrics.get("bootstrap_recovery_attempts", 0) or 0
+ ),
+ "bootstrap_health_state": str(
+ metrics.get("bootstrap_health_state", "unknown") or "unknown"
+ ),
+ "bootstrap_zero_state_count": int(
+ metrics.get("bootstrap_zero_state_count", 0) or 0
+ ),
+ "bootstrap_zero_nodes_last_reason": str(
+ metrics.get("bootstrap_zero_nodes_last_reason", "") or ""
+ ),
+ "rebootstrap_attempt_count": int(
+ metrics.get("rebootstrap_attempt_count", 0) or 0
+ ),
+ "rebootstrap_success_count": int(
+ metrics.get("rebootstrap_success_count", 0) or 0
+ ),
+ "rebootstrap_failure_count": int(
+ metrics.get("rebootstrap_failure_count", 0) or 0
+ ),
+ "rebootstrap_last_outcome": str(
+ metrics.get("rebootstrap_last_outcome", "not_attempted")
+ or "not_attempted"
+ ),
+ "rebootstrap_last_reason": str(
+ metrics.get("rebootstrap_last_reason", "") or ""
+ ),
+ "rebootstrap_last_source": str(
+ metrics.get("rebootstrap_last_source", "") or ""
+ ),
+ "rebootstrap_health_state": str(
+ metrics.get("rebootstrap_health_state", "unknown") or "unknown"
+ ),
+ "rebootstrap_consecutive_failures": int(
+ metrics.get("rebootstrap_consecutive_failures", 0) or 0
+ ),
+ "last_bootstrap_reason": str(
+ metrics.get("last_bootstrap_reason", "") or ""
+ ),
+ "last_bootstrap_failure_reason": str(
+ metrics.get("last_bootstrap_failure_reason", "") or ""
+ ),
+ "last_zero_node_lookup_at": float(
+ metrics.get("last_zero_node_lookup_at", 0.0) or 0.0
+ ),
}
response = DHTQueryMetricsResponse(**typed_metrics) # type: ignore[arg-type]
return web.json_response(response.model_dump()) # type: ignore[attr-defined]
@@ -1991,27 +2139,22 @@ def get_download_rate(item: tuple[str, dict[str, Any], Any]) -> float:
else 0.0
)
- # Get active peers count
+ # Align with session peer manager: connected post-handshake peers,
+ # not only those with non-zero rate (choked/idle peers still matter).
active_peers = 0
if hasattr(torrent_session, "download_manager"):
download_manager = torrent_session.download_manager
if hasattr(download_manager, "peer_manager"):
peer_manager = download_manager.peer_manager
if peer_manager and hasattr(
+ peer_manager, "get_active_peers"
+ ):
+ ap = peer_manager.get_active_peers()
+ active_peers = len(ap) if ap else 0
+ elif peer_manager and hasattr(
peer_manager, "connections"
):
- # Count active peers (those with download/upload activity)
- active_peers = sum(
- 1
- for conn in peer_manager.connections.values()
- if hasattr(conn, "stats")
- and (
- getattr(conn.stats, "download_rate", 0.0)
- > 0
- or getattr(conn.stats, "upload_rate", 0.0)
- > 0
- )
- )
+ active_peers = len(peer_manager.connections)
sample = SwarmHealthSample(
info_hash=info_hash_hex,
@@ -2021,9 +2164,7 @@ def get_download_rate(item: tuple[str, dict[str, Any], Any]) -> float:
download_rate=float(status.get("download_rate", 0.0)),
upload_rate=float(status.get("upload_rate", 0.0)),
connected_peers=int(
- status.get(
- "connected_peers", status.get("num_peers", 0)
- ),
+ status.get("connected_peers", 0),
),
active_peers=active_peers,
progress=float(status.get("progress", 0.0)),
@@ -2185,6 +2326,7 @@ async def _handle_aggressive_discovery_status(self, request: Request) -> Respons
async def _handle_add_torrent(self, request: Request) -> Response:
"""Handle POST /api/v1/torrents/add."""
info_hash_hex: Optional[str] = None
+ visibility_ready = True
path_or_magnet: str = "unknown"
try:
# Parse JSON request body with error handling
@@ -2234,17 +2376,17 @@ async def _handle_add_torrent(self, request: Request) -> Response:
status=400,
)
- # CRITICAL FIX: Use executor pattern for consistency with all other handlers
+ # Note: Use executor pattern for consistency with all other handlers
# Add timeout protection for add operations
# This prevents the request from hanging indefinitely if something goes wrong
# The timeout is generous (120s for magnets) to allow for metadata exchange
try:
# Use executor to add torrent/magnet (consistent with all other handlers)
- # CRITICAL FIX: Increase timeout for magnets to allow metadata exchange
+ # Note: Increase timeout for magnets to allow metadata exchange
# Magnet links need time to fetch metadata from peers, which can take 30-120s
timeout = 120.0 if req.path_or_magnet.startswith("magnet:") else 60.0
- # CRITICAL FIX: Wrap executor.execute in additional try-except to catch any
+ # Note: Wrap executor.execute in additional try-except to catch any
# unexpected exceptions that might not be caught by the executor itself
try:
result = await asyncio.wait_for(
@@ -2324,6 +2466,14 @@ async def _handle_add_torrent(self, request: Request) -> Response:
).model_dump(),
status=400,
)
+ # Visibility can lag behind successful add completion.
+ # Preserve this as a signal in the success payload instead of hard-failing.
+ visibility_ready = await self._wait_for_add_visibility(info_hash_hex)
+ if not visibility_ready:
+ logger.warning(
+ "Add returned info_hash=%s but session registration is not visible yet",
+ info_hash_hex,
+ )
except Exception as add_error:
# Catch any other unexpected errors (shouldn't happen due to inner try-except)
# But this is a safety net to ensure the daemon never crashes
@@ -2339,7 +2489,7 @@ async def _handle_add_torrent(self, request: Request) -> Response:
status=500,
)
- # CRITICAL FIX: Emit WebSocket event with error isolation
+ # Note: Emit WebSocket event with error isolation
# WebSocket errors should not prevent the torrent from being added
# If the torrent was successfully added, return success even if WebSocket fails
try:
@@ -2358,12 +2508,20 @@ async def _handle_add_torrent(self, request: Request) -> Response:
)
# Return success if torrent was added (even if WebSocket event failed)
- # CRITICAL FIX: This check should never be reached if the inner try-except
+ # Note: This check should never be reached if the inner try-except
# handled the case correctly, but we include it as a safety net
if info_hash_hex:
- return web.json_response(
- {"info_hash": info_hash_hex, "status": "added"}
- ) # type: ignore[attr-defined]
+ response_payload: dict[str, Any] = {
+ "info_hash": info_hash_hex,
+ "status": "added",
+ "visibility_ready": visibility_ready,
+ }
+ if not visibility_ready:
+ response_payload["warning_code"] = "ADD_VISIBILITY_NOT_READY"
+ response_payload["warning"] = (
+ "Torrent add completed but registration is not visible yet"
+ )
+ return web.json_response(response_payload) # type: ignore[attr-defined]
# This should never happen due to the check at lines 672-684, but handle it gracefully
logger.error(
"Torrent was not added (info_hash is None) - this should not happen",
@@ -2387,6 +2545,30 @@ async def _handle_add_torrent(self, request: Request) -> Response:
status=400,
)
+ async def _wait_for_add_visibility(
+ self,
+ info_hash_hex: str,
+ *,
+ timeout_s: float = 1.0,
+ poll_interval_s: float = 0.02,
+ ) -> bool:
+ """Wait until add registration is visible to inbound session lookup."""
+ try:
+ info_hash_bytes = bytes.fromhex(info_hash_hex)
+ except (TypeError, ValueError):
+ return False
+
+ deadline = asyncio.get_running_loop().time() + timeout_s
+ while True:
+ session = await self.session_manager.get_session_for_info_hash(
+ info_hash_bytes
+ )
+ if session is not None:
+ return True
+ if asyncio.get_running_loop().time() >= deadline:
+ return False
+ await asyncio.sleep(poll_interval_s)
+
async def _handle_remove_torrent(self, request: Request) -> Response:
"""Handle DELETE /api/v1/torrents/{info_hash}."""
info_hash = request.match_info["info_hash"]
@@ -3705,17 +3887,71 @@ async def _handle_update_config(self, request: Request) -> Response:
async def _handle_shutdown(self, _request: Request) -> Response:
"""Handle POST /api/v1/shutdown."""
logger.info("Shutdown requested via IPC")
- # Schedule shutdown (don't block the response) - fire-and-forget
- asyncio.create_task(self._shutdown_async()) # noqa: RUF006
- # Don't await - let it run after response is sent
- return web.json_response({"status": "shutting_down"}) # type: ignore[attr-defined]
+
+ if self._shutdown_event is not None and self._shutdown_event.is_set():
+ return web.json_response( # type: ignore[attr-defined]
+ {
+ "accepted": True,
+ "status": "already_shutting_down",
+ "message": "Shutdown already in progress",
+ }
+ )
+
+ if self._shutdown_callback is None and self._shutdown_event is None:
+ logger.error(
+ "Shutdown request rejected: daemon shutdown bridge unavailable"
+ )
+ return web.json_response( # type: ignore[attr-defined]
+ {
+ "accepted": False,
+ "status": "rejected",
+ "error": "Shutdown handler unavailable",
+ "fallback_hint": "Use signal-based shutdown path",
+ },
+ status=503,
+ )
+
+ try:
+ # Schedule shutdown (don't block the response) - fire-and-forget
+ asyncio.create_task(self._shutdown_async()) # noqa: RUF006
+ except Exception:
+ logger.exception("Failed to enqueue shutdown task")
+ return web.json_response( # type: ignore[attr-defined]
+ {
+ "accepted": False,
+ "status": "enqueue_failed",
+ "error": "Failed to enqueue shutdown task",
+ "fallback_hint": "Use signal-based shutdown path",
+ },
+ status=500,
+ )
+
+ return web.json_response( # type: ignore[attr-defined]
+ {
+ "accepted": True,
+ "status": "shutting_down",
+ "message": "Shutdown enqueued",
+ }
+ )
async def _shutdown_async(self) -> None:
"""Async shutdown handler."""
await asyncio.sleep(0.1) # Give response time to send
- # Signal shutdown to daemon main (this will be handled by DaemonMain)
- # For now, we'll just log it
- logger.info("Shutdown signal sent")
+ if self._shutdown_event is not None and self._shutdown_event.is_set():
+ logger.debug("Shutdown event already set; skipping duplicate IPC signal")
+ return
+
+ if self._shutdown_callback is not None:
+ try:
+ await self._shutdown_callback()
+ logger.info("Shutdown signal sent")
+ return
+ except Exception:
+ logger.exception("Shutdown callback failed")
+
+ if self._shutdown_event is not None:
+ self._shutdown_event.set()
+ logger.info("Shutdown event set from IPC")
async def _handle_restart_service(self, request: Request) -> Response:
"""Handle POST /api/v1/services/{service_name}/restart."""
@@ -4873,6 +5109,97 @@ async def _handle_get_global_stats(self, _request: Request) -> Response:
)
return web.json_response(response.model_dump()) # type: ignore[attr-defined]
+ async def _handle_ui_snapshot(self, _request: Request) -> Response:
+ """Handle GET /api/v1/ui/snapshot - single response for dashboard first-paint."""
+ try:
+ # Global stats
+ stats_result = await self.executor.execute("session.get_global_stats")
+ if not stats_result.success:
+ return web.json_response( # type: ignore[attr-defined]
+ ErrorResponse(
+ error=stats_result.error or "Failed to get global stats",
+ code="SESSION_ERROR",
+ ).model_dump(),
+ status=500,
+ )
+ stats = stats_result.data.get("stats", {})
+ global_stats = dict(stats)
+ global_stats.setdefault(
+ "total_download_rate",
+ stats.get("download_rate", stats.get("total_download_rate", 0.0)),
+ )
+ global_stats.setdefault(
+ "total_upload_rate",
+ stats.get("upload_rate", stats.get("total_upload_rate", 0.0)),
+ )
+
+ # Torrent list
+ list_result = await self.executor.execute("torrent.list")
+ torrents_raw = (
+ list_result.data.get("torrents", []) if list_result.success else []
+ )
+ torrents = [
+ t.model_dump() if hasattr(t, "model_dump") else t for t in torrents_raw
+ ]
+
+ # Services status (same shape as GET /services/status)
+ services_status = {"services": {}}
+ if self.session_manager:
+ s = services_status["services"]
+ s["dht"] = {
+ "enabled": self.session_manager.dht_client is not None,
+ "status": "running"
+ if self.session_manager.dht_client
+ else "stopped",
+ }
+ s["nat"] = {
+ "enabled": self.session_manager.nat_manager is not None,
+ "status": "running"
+ if self.session_manager.nat_manager
+ else "stopped",
+ }
+ s["tcp_server"] = {
+ "enabled": self.session_manager.tcp_server is not None,
+ "status": "running"
+ if self.session_manager.tcp_server
+ else "stopped",
+ }
+ s["peer_service"] = {
+ "enabled": self.session_manager.peer_service is not None,
+ "status": "running"
+ if self.session_manager.peer_service
+ else "stopped",
+ }
+ services_status["services"]["ipc_server"] = {
+ "enabled": True,
+ "status": "running",
+ }
+
+ # Rate samples (truncated for first-paint graph)
+ rate_samples = []
+ try:
+ samples_raw = await self.session_manager.get_rate_samples(60)
+ rate_samples = (samples_raw or [])[-30:]
+ except Exception as e:
+ logger.debug("UI snapshot: rate samples unavailable: %s", e)
+
+ response = UISnapshotResponse(
+ global_stats=global_stats,
+ torrents=torrents,
+ services_status=services_status,
+ rate_samples=rate_samples,
+ )
+ return web.json_response(response.model_dump()) # type: ignore[attr-defined]
+ except Exception as e:
+ logger.exception("Error building UI snapshot")
+ return web.json_response( # type: ignore[attr-defined]
+ ErrorResponse(
+ error=str(e),
+ code="UI_SNAPSHOT_ERROR",
+ ).model_dump(),
+ status=500,
+ )
+
async def _handle_global_pause_all(self, _request: Request) -> Response:
"""Handle POST /api/v1/global/pause-all."""
try:
@@ -5468,6 +5795,8 @@ async def setup_event_bridge(self) -> None:
"peer_added": EventType.PEER_CONNECTED, # Map to PEER_CONNECTED
"peer_removed": EventType.PEER_DISCONNECTED, # Map to PEER_DISCONNECTED
"peer_connection_failed": EventType.PEER_DISCONNECTED, # Map to PEER_DISCONNECTED
+ "peer_quality_ranked": EventType.PEER_QUALITY_RANKED,
+ "peer_choking_optimized": EventType.GLOBAL_STATS_UPDATED,
# Piece events
"piece_requested": EventType.PIECE_REQUESTED,
"piece_downloaded": EventType.PIECE_DOWNLOADED,
@@ -5552,17 +5881,36 @@ async def event_bridge_handler(event: Event) -> None:
event_data = self._normalize_xet_event_data(
ipc_event_type, event_data
)
+ if hasattr(event, "priority"):
+ event_priority = event.priority
+ else:
+ event_priority = None
+ if isinstance(event_priority, int):
+ event_priority_text = {
+ 1: "low",
+ 2: "normal",
+ 3: "high",
+ 4: "critical",
+ }.get(event_priority)
+ elif isinstance(event_priority, str):
+ event_priority_text = event_priority.lower()
+ elif event_priority is not None and hasattr(
+ event_priority, "name"
+ ):
+ event_priority_text = str(event_priority.name).lower()
+ else:
+ event_priority_text = (
+ str(event_priority).lower()
+ if event_priority is not None
+ else None
+ )
await self.emit_websocket_event(
ipc_event_type,
event_data,
raw_type=event.event_type,
event_id=getattr(event, "event_id", None),
source=getattr(event, "source", None),
- priority=(
- event.priority.value
- if getattr(event, "priority", None) is not None
- else None
- ),
+ priority=event_priority_text,
correlation_id=getattr(event, "correlation_id", None),
)
except Exception as e:
@@ -5634,13 +5982,29 @@ async def emit_websocket_event(
if not self.websocket_enabled:
return
+ normalized_priority = None
+ if priority is not None:
+ if isinstance(priority, int):
+ normalized_priority = {
+ 1: "low",
+ 2: "normal",
+ 3: "high",
+ 4: "critical",
+ }.get(priority)
+ elif isinstance(priority, str):
+ normalized_priority = priority.lower()
+ elif hasattr(priority, "name"):
+ normalized_priority = str(priority.name).lower()
+ elif priority is not None:
+ normalized_priority = str(priority)
+
event = WebSocketEvent(
type=event_type,
timestamp=time.time(),
raw_type=raw_type or event_type.value,
event_id=event_id,
source=source,
- priority=priority,
+ priority=normalized_priority,
correlation_id=correlation_id,
data=data,
)
diff --git a/ccbt/daemon/main.py b/ccbt/daemon/main.py
index 8730e976..0d5183f1 100644
--- a/ccbt/daemon/main.py
+++ b/ccbt/daemon/main.py
@@ -1,14 +1,15 @@
"""Daemon main entry point.
-from __future__ import annotations
-
-Main entry point for background daemon process.
+IPC add-torrent / add-magnet handlers await ``AsyncSessionManager.add_*`` before
+reporting success so registration is visible to inbound TCP and
+``get_session_for_info_hash`` immediately after the call returns.
"""
from __future__ import annotations
import asyncio
import contextlib
+import logging
import sys
from typing import TYPE_CHECKING, Any, Callable, Coroutine, Optional
@@ -16,6 +17,7 @@
from pathlib import Path
from ccbt.config.config import init_config
+from ccbt.config.env_bootstrap import maybe_load_dotenv_from_env
from ccbt.daemon.daemon_manager import DaemonManager
from ccbt.daemon.ipc_protocol import EventType
from ccbt.daemon.ipc_server import IPCServer # type: ignore[attr-defined]
@@ -27,6 +29,13 @@
logger = get_logger(__name__)
+def _flush_log_handlers() -> None:
+ """Best-effort flush so shutdown logs reach files before process exit."""
+ for handler in logging.root.handlers:
+ with contextlib.suppress(Exception):
+ handler.flush()
+
+
def _is_workspace_id_hex(workspace_id_hex: str) -> bool:
"""Return True when workspace ID is canonical 32-byte hex."""
if len(workspace_id_hex) != 64:
@@ -150,7 +159,7 @@ async def start(self) -> None:
"""Start daemon process."""
logger.info("Starting ccBitTorrent daemon...")
- # CRITICAL FIX: Acquire lock file EARLY in startup process
+ # Note: Acquire lock file EARLY in startup process
# This prevents multiple daemon instances from starting simultaneously
# Must be done BEFORE any initialization to prevent resource conflicts
if not self.daemon_manager.acquire_lock():
@@ -214,7 +223,7 @@ async def start(self) -> None:
# Setup signal handlers (before writing PID file)
self.daemon_manager.setup_signal_handlers(self._shutdown_handler)
- # CRITICAL FIX: Initialize security components BEFORE session manager
+ # Note: Initialize security components BEFORE session manager
# This ensures API key, Ed25519 keys, and TLS are ready before NAT manager starts
# Security initialization must happen before any network components
daemon_config = self.config.daemon
@@ -313,7 +322,7 @@ async def start(self) -> None:
"Error initializing metrics collection, continuing without metrics"
)
- # CRITICAL FIX: IPC server initialization moved here (after session manager start)
+ # Note: IPC server initialization moved here (after session manager start)
# Security components were initialized earlier, so we can use them now
# Get IPC configuration
ipc_host = daemon_config.ipc_host if daemon_config else "127.0.0.1"
@@ -325,7 +334,7 @@ async def start(self) -> None:
daemon_config.websocket_heartbeat_interval if daemon_config else 30.0
)
- # CRITICAL FIX: Check if IPC port is available before attempting to bind
+ # Note: Check if IPC port is available before attempting to bind
from ccbt.utils.port_checker import (
get_port_conflict_resolution,
is_port_available,
@@ -334,7 +343,7 @@ async def start(self) -> None:
bind_host = ipc_host if ipc_host != "0.0.0.0" else "127.0.0.1" # nosec B104 - IPC server converts 0.0.0.0 to 127.0.0.1 for localhost-only binding
port_available, port_error = is_port_available(bind_host, ipc_port, "tcp")
if not port_available:
- # CRITICAL FIX: Distinguish between permission errors and port conflicts
+ # Note: Distinguish between permission errors and port conflicts
# Check for permission denied in multiple ways (error code 10013 on Windows, 13 on Unix)
from ccbt.utils.port_checker import get_permission_error_resolution
@@ -379,9 +388,11 @@ async def start(self) -> None:
websocket_enabled=websocket_enabled,
websocket_heartbeat_interval=websocket_heartbeat,
tls_enabled=self._tls_enabled,
+ shutdown_callback=self._shutdown_handler,
+ shutdown_event=self._shutdown_event,
)
- # CRITICAL FIX: Set up session manager callbacks to emit WebSocket events
+ # Note: Set up session manager callbacks to emit WebSocket events
# This ensures completion events are properly propagated to clients
async def on_torrent_complete_callback(info_hash: bytes, name: str) -> None:
"""Handle torrent completion and emit WebSocket event."""
@@ -428,7 +439,7 @@ async def on_torrent_complete_callback(info_hash: bytes, name: str) -> None:
# Set up event bridge to convert utils.events to IPC WebSocket events
await self.ipc_server.setup_event_bridge()
- # CRITICAL FIX: Verify IPC server is actually accepting HTTP connections before writing PID file
+ # Note: Verify IPC server is actually accepting HTTP connections before writing PID file
# Socket test alone isn't sufficient - aiohttp might not be ready for HTTP yet
# This ensures CLI can connect immediately after PID file is written
import aiohttp
@@ -509,7 +520,7 @@ async def on_torrent_complete_callback(info_hash: bytes, name: str) -> None:
)
raise RuntimeError(error_msg)
- # CRITICAL FIX: Write PID file ONLY after IPC server is ready
+ # Note: Write PID file ONLY after IPC server is ready
# This ensures CLI can connect immediately after PID file is written
# Lock is already acquired at start of this method
self.daemon_manager.write_pid(acquire_lock=False)
@@ -695,7 +706,7 @@ async def on_torrent_complete_callback(info_hash: bytes, name: str) -> None:
logger.info("Daemon started successfully")
except Exception:
- # CRITICAL FIX: Remove PID file if startup fails
+ # Note: Remove PID file if startup fails
# This prevents CLI from thinking daemon is running when it crashed
logger.exception("Failed to start daemon, cleaning up PID file and lock")
try:
@@ -761,7 +772,7 @@ async def run(self) -> None:
try:
# Wait for shutdown signal
- # CRITICAL FIX: Use an infinite loop with periodic checks instead of await wait()
+ # Note: Use an infinite loop with periodic checks instead of await wait()
# On Windows, await event.wait() may not keep the event loop alive if there are no other tasks
# The IPC server site should create tasks, but we need to ensure the loop stays alive
logger.debug("Waiting for shutdown signal...")
@@ -782,7 +793,7 @@ async def run(self) -> None:
# Don't check sockets - this can be unreliable and cause false positives
# The site.start() already verified the server is listening
- # CRITICAL FIX: Use a loop with periodic sleep to keep the event loop alive
+ # Note: Use a loop with periodic sleep to keep the event loop alive
# This ensures the daemon stays running even on Windows where event.wait() might not be enough
# The periodic sleep creates tasks that keep the event loop from exiting
# Also verify IPC server is still running periodically
@@ -792,7 +803,7 @@ async def run(self) -> None:
from ccbt.daemon.debug_utils import debug_log, debug_log_event_loop_state
- # CRITICAL FIX: Initialize keep_alive to None to ensure it's always in scope
+ # Note: Initialize keep_alive to None to ensure it's always in scope
# This prevents NameError if exception occurs before task creation
keep_alive: Optional[asyncio.Task] = None
@@ -831,7 +842,7 @@ async def keep_alive_task():
debug_log("Entering main loop - waiting for shutdown signal")
while not self._shutdown_event.is_set():
try:
- # CRITICAL FIX: Use wait with timeout for more responsive shutdown
+ # Note: Use wait with timeout for more responsive shutdown
# This allows the loop to check the shutdown event more frequently
# while still keeping the event loop alive
try:
@@ -844,12 +855,12 @@ async def keep_alive_task():
break
except asyncio.TimeoutError:
# Timeout is expected - continue loop to check again
- # CRITICAL FIX: Check shutdown event immediately after timeout
+ # Note: Check shutdown event immediately after timeout
# This ensures we break immediately if shutdown was requested
if self._shutdown_event.is_set():
break
except KeyboardInterrupt:
- # CRITICAL FIX: Handle KeyboardInterrupt by setting shutdown event and breaking
+ # Note: Handle KeyboardInterrupt by setting shutdown event and breaking
# Don't re-raise - let the signal handler and outer handler deal with it
# The signal handler should have already set the shutdown event, but set it here too
logger.info(
@@ -863,7 +874,7 @@ async def keep_alive_task():
# Break out of the loop immediately
break
- # CRITICAL FIX: Check shutdown event again before continuing
+ # Note: Check shutdown event again before continuing
# This ensures we break immediately if shutdown was requested during the wait
if self._shutdown_event.is_set():
break
@@ -914,7 +925,7 @@ async def keep_alive_task():
debug_log_stack("Stack when loop access failed")
break
- # CRITICAL FIX: Check shutdown event one more time before sleep
+ # Note: Check shutdown event one more time before sleep
# This ensures we break immediately if shutdown was requested
if self._shutdown_event.is_set():
break
@@ -930,7 +941,7 @@ async def keep_alive_task():
)
break
except KeyboardInterrupt:
- # CRITICAL FIX: Handle KeyboardInterrupt by setting shutdown event and breaking
+ # Note: Handle KeyboardInterrupt by setting shutdown event and breaking
# The signal handler should have already set the shutdown event, but set it here too
logger.info(
"KeyboardInterrupt detected in main loop (outer handler)"
@@ -967,7 +978,7 @@ async def keep_alive_task():
# Reset counter to allow recovery
consecutive_errors = 0
- # CRITICAL FIX: Check shutdown event before sleep
+ # Note: Check shutdown event before sleep
# This ensures we break immediately if shutdown was requested
if self._shutdown_event.is_set():
break
@@ -978,7 +989,7 @@ async def keep_alive_task():
logger.info("Shutdown signal received")
finally:
# Cancel keep-alive task
- # CRITICAL FIX: Check if keep_alive exists and is not done before cancelling
+ # Note: Check if keep_alive exists and is not done before cancelling
if keep_alive is not None and not keep_alive.done():
keep_alive.cancel()
with contextlib.suppress(
@@ -992,7 +1003,7 @@ async def keep_alive_task():
debug_log("Received keyboard interrupt")
debug_log_stack("Stack after KeyboardInterrupt")
- # CRITICAL FIX: Set global shutdown flag early to suppress verbose logging
+ # Note: Set global shutdown flag early to suppress verbose logging
try:
from ccbt.utils.shutdown import set_shutdown
@@ -1000,12 +1011,12 @@ async def keep_alive_task():
except Exception:
pass # Don't fail if shutdown module isn't available
- # CRITICAL FIX: Set shutdown event when KeyboardInterrupt is caught
+ # Note: Set shutdown event when KeyboardInterrupt is caught
# This ensures shutdown happens even if signal handler didn't execute
self._shutdown_event.set()
logger.debug("Shutdown event set from KeyboardInterrupt handler")
- # CRITICAL FIX: Cancel keep-alive task immediately to ensure quick shutdown
+ # Note: Cancel keep-alive task immediately to ensure quick shutdown
# This prevents the task from continuing to run after KeyboardInterrupt
if keep_alive is not None and not keep_alive.done():
keep_alive.cancel()
@@ -1016,7 +1027,7 @@ async def keep_alive_task():
keep_alive, timeout=1.0
) # Expected during cancellation
- # CRITICAL FIX: Cancel all remaining tasks to ensure clean shutdown
+ # Note: Cancel all remaining tasks to ensure clean shutdown
# This prevents tasks from blocking shutdown
try:
current_task = asyncio.current_task()
@@ -1038,7 +1049,7 @@ async def keep_alive_task():
except Exception as e:
logger.debug("Error cancelling tasks: %s", e)
- # CRITICAL FIX: Call stop() directly in KeyboardInterrupt handler
+ # Note: Call stop() directly in KeyboardInterrupt handler
# This ensures proper shutdown even if asyncio.run() cancels the event loop
# We do this here instead of relying on the finally block because
# asyncio.run() may cancel tasks and close the loop before finally executes
@@ -1090,7 +1101,7 @@ async def keep_alive_task():
logger.info("Daemon main loop exiting, starting shutdown...")
debug_log("Daemon main loop exiting, starting shutdown...")
debug_log_stack("Stack in finally block before stop()")
- # CRITICAL FIX: Only call stop() if it hasn't been called already
+ # Note: Only call stop() if it hasn't been called already
# (e.g., from KeyboardInterrupt handler)
if not self._stopping:
try:
@@ -1105,20 +1116,20 @@ async def keep_alive_task():
async def stop(self) -> None:
"""Stop daemon process with proper shutdown sequence."""
- # CRITICAL FIX: Make stop() idempotent to prevent double-calling
+ # Note: Make stop() idempotent to prevent double-calling
if self._stopping:
logger.debug("Stop() already in progress, skipping duplicate call")
return
self._stopping = True
- # CRITICAL FIX: Set global shutdown flag early to suppress verbose logging
+ # Note: Set global shutdown flag early to suppress verbose logging
from ccbt.utils.shutdown import set_shutdown
set_shutdown()
- logger.info("Stopping daemon...")
+ logger.info("Daemon shutdown sequence started")
- # CRITICAL FIX: Verify daemon is actually running before stopping
+ # Note: Verify daemon is actually running before stopping
# This prevents issues with stale PID files
try:
pid = self.daemon_manager.get_pid()
@@ -1161,6 +1172,14 @@ async def stop(self) -> None:
except Exception:
logger.exception("Error stopping IPC server")
+ # Ask all sessions to quiesce before state-save and full stop sequence.
+ if self.session_manager:
+ try:
+ self.session_manager.begin_shutdown_quiesce()
+ logger.debug("Session manager pre-quiesce completed")
+ except Exception:
+ logger.exception("Error in session manager pre-quiesce")
+
# Save state (after IPC stopped so no handler blocks lock acquisition)
if self.session_manager:
try:
@@ -1170,9 +1189,10 @@ async def stop(self) -> None:
logger.exception("Error saving state during shutdown")
# Stop session manager (releases all network ports via TCP server, UDP tracker, DHT, NAT)
+ session_manager_stop_failed = False
if self.session_manager:
try:
- # CRITICAL FIX: Add delay before stopping session manager on Windows
+ # Note: Add delay before stopping session manager on Windows
# This prevents socket buffer exhaustion (WinError 10055) when closing many sockets at once
import sys
@@ -1181,7 +1201,7 @@ async def stop(self) -> None:
await self.session_manager.stop()
logger.debug("Session manager stopped (all ports released)")
except OSError as e:
- # CRITICAL FIX: Handle WinError 10055 gracefully during shutdown
+ # Note: Handle WinError 10055 gracefully during shutdown
error_code = getattr(e, "winerror", None) or getattr(e, "errno", None)
if error_code == 10055:
logger.warning(
@@ -1189,14 +1209,23 @@ async def stop(self) -> None:
"This is a transient Windows issue. Continuing shutdown..."
)
else:
+ session_manager_stop_failed = True
logger.exception("OSError stopping session manager")
except Exception:
+ session_manager_stop_failed = True
logger.exception("Error stopping session manager")
+ if session_manager_stop_failed:
+ logger.error(
+ "Daemon shutdown incomplete: session manager stop raised errors; "
+ "see logs above. Removing PID file anyway."
+ )
+
# Remove PID file and release lock (must be last)
self.daemon_manager.remove_pid()
logger.info("Daemon stopped (all ports released, PID file removed)")
+ _flush_log_handlers()
async def main() -> int:
@@ -1205,11 +1234,13 @@ async def main() -> int:
parser = argparse.ArgumentParser(description="ccBitTorrent Daemon")
parser.add_argument(
+ "-c",
"--config",
type=str,
help="Path to config file",
)
parser.add_argument(
+ "-f",
"--foreground",
action="store_true",
help="Run in foreground (for debugging)",
@@ -1222,9 +1253,10 @@ async def main() -> int:
help="Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)",
)
parser.add_argument(
+ "-l",
"--log-level",
type=str,
- choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"],
+ choices=["DEBUG", "TRACE", "INFO", "WARNING", "ERROR", "CRITICAL"],
help="Set log level directly",
)
@@ -1242,6 +1274,7 @@ async def main() -> int:
# This ensures we can log errors during initialization
try:
debug_log("Initializing configuration...")
+ maybe_load_dotenv_from_env()
config_manager = init_config(args.config)
# Set locale from config so any user-facing log or IPC messages use the same locale
try:
@@ -1252,23 +1285,28 @@ async def main() -> int:
pass
debug_log("Configuration initialized, setting up logging...")
- # CRITICAL FIX: Apply verbosity/log-level overrides from CLI arguments
+ # Note: Apply verbosity/log-level overrides from CLI arguments
# This ensures daemon respects verbosity flags just like CLI commands
+ effective_log_level = config_manager.config.observability.log_level
if args.log_level:
from ccbt.models import LogLevel
- config_manager.config.observability.log_level = LogLevel(args.log_level)
+ effective_log_level = LogLevel(args.log_level)
elif args.verbose > 0:
from ccbt.cli.verbosity import VerbosityManager
from ccbt.models import LogLevel
verbosity_manager = VerbosityManager.from_count(args.verbose)
- if verbosity_manager.is_debug():
- config_manager.config.observability.log_level = LogLevel.DEBUG
+ if verbosity_manager.is_trace():
+ effective_log_level = verbosity_manager.logging_level_for_verbosity()
+ elif verbosity_manager.is_debug():
+ effective_log_level = LogLevel.DEBUG
elif verbosity_manager.is_verbose():
- config_manager.config.observability.log_level = LogLevel.INFO
+ effective_log_level = LogLevel.INFO
- setup_logging(config_manager.config.observability)
+ setup_logging(
+ config_manager.config.observability, effective_log_level=effective_log_level
+ )
# Get logger after setup_logging
from ccbt.utils.logging_config import get_logger
@@ -1288,7 +1326,7 @@ async def main() -> int:
logger = logging.getLogger(__name__)
logger.warning("Using fallback logging configuration")
- # CRITICAL FIX: Set up event loop exception handler to catch unhandled exceptions
+ # Note: Set up event loop exception handler to catch unhandled exceptions
# in background tasks. This prevents the daemon from crashing when background tasks
# raise unhandled exceptions (e.g., from session.start() creating tasks).
# The handler is set up here after the loop is created by asyncio.run()
@@ -1310,7 +1348,7 @@ def exception_handler(
# NOTE: KeyboardInterrupt should propagate naturally from the main coroutine
# We don't catch it here because it needs to reach the KeyboardInterrupt handler in run()
- # CRITICAL FIX: Suppress CancelledError logging during shutdown
+ # Note: Suppress CancelledError logging during shutdown
# CancelledError is expected when tasks are cancelled during shutdown
if isinstance(exception, asyncio.CancelledError):
from ccbt.utils.shutdown import is_shutting_down
@@ -1326,7 +1364,7 @@ def exception_handler(
)
return
- # CRITICAL FIX: Handle Windows socket buffer exhaustion (WinError 10055) gracefully
+ # Note: Handle Windows socket buffer exhaustion (WinError 10055) gracefully
# This can occur:
# 1. In the event loop selector during shutdown when many sockets are closed
# 2. During normal operation when too many sockets are registered simultaneously
@@ -1357,13 +1395,13 @@ def exception_handler(
# The error will propagate but we've logged it
return # Don't log as error - we've handled it above
- # CRITICAL FIX: Suppress verbose logging during shutdown
+ # Note: Suppress verbose logging during shutdown
from ccbt.utils.shutdown import is_shutting_down
if is_shutting_down():
# During shutdown, only log critical errors, not routine exceptions
# This prevents log flooding when tasks are being cancelled
- # CRITICAL FIX: Suppress PeerConnectionError during shutdown (connection tasks being cancelled)
+ # Note: Suppress PeerConnectionError during shutdown (connection tasks being cancelled)
if isinstance(exception, Exception):
# Check if this is a connection-related error that's expected during shutdown
try:
@@ -1442,7 +1480,7 @@ def exception_handler(
debug_log("DaemonMain instance created successfully")
debug_log_stack("Stack after DaemonMain creation")
- # CRITICAL FIX: Run daemon in a way that ensures the event loop stays alive
+ # Note: Run daemon in a way that ensures the event loop stays alive
# Wrap in try-except to catch any unexpected exits and log them
try:
debug_log("Starting daemon.run()...")
@@ -1492,7 +1530,7 @@ def exception_handler(
return e.code if isinstance(e.code, int) else 0
except Exception:
logger.exception("Fatal error in daemon")
- # CRITICAL FIX: Ensure PID file is removed on fatal error
+ # Note: Ensure PID file is removed on fatal error
# This is a safety net in case start() didn't clean up
if daemon is not None:
try:
@@ -1504,7 +1542,7 @@ def exception_handler(
if __name__ == "__main__":
- # CRITICAL FIX: Suppress ProactorEventLoop _ssock AttributeError on Windows
+ # Note: Suppress ProactorEventLoop _ssock AttributeError on Windows
# This is a known Python bug where ProactorEventLoop.__del__ tries to access
# _ssock attribute that doesn't exist in some cases during cleanup
import sys
@@ -1541,7 +1579,7 @@ def filtered_excepthook(exc_type, exc_value, exc_traceback):
sys.excepthook = filtered_excepthook
- # CRITICAL FIX: Use SelectorEventLoop instead of ProactorEventLoop on Windows
+ # Note: Use SelectorEventLoop instead of ProactorEventLoop on Windows
# ProactorEventLoop has known bugs with UDP sockets (WinError 10022)
# SelectorEventLoop uses select() which properly supports UDP on Windows
# Note: Policy should already be set in ccbt/__init__.py, but ensure it here as well
@@ -1572,7 +1610,7 @@ def filtered_excepthook(exc_type, exc_value, exc_traceback):
else:
asyncio.set_event_loop_policy(selector_policy)
- # CRITICAL FIX: Add better error handling to prevent premature exit
+ # Note: Add better error handling to prevent premature exit
# This ensures the daemon stays alive and handles errors gracefully
# Note: Event loop exception handler is set inside main() after the loop is created
try:
@@ -1582,7 +1620,7 @@ def filtered_excepthook(exc_type, exc_value, exc_traceback):
# User interrupted - exit cleanly
sys.exit(0)
except OSError as e:
- # CRITICAL FIX: Handle Windows socket buffer exhaustion (WinError 10055)
+ # Note: Handle Windows socket buffer exhaustion (WinError 10055)
# This can occur:
# 1. During shutdown when many sockets are closed at once
# 2. During normal operation when the event loop selector hits buffer limits
diff --git a/ccbt/daemon/state_manager.py b/ccbt/daemon/state_manager.py
index bfe4e035..c1f80073 100644
--- a/ccbt/daemon/state_manager.py
+++ b/ccbt/daemon/state_manager.py
@@ -55,7 +55,7 @@ def __init__(self, state_dir: Optional[str | Path] = None):
"""
if state_dir is None:
- # CRITICAL FIX: Use consistent path resolution helper to match daemon
+ # Note: Use consistent path resolution helper to match daemon
from ccbt.daemon.daemon_manager import _get_daemon_home_dir
home_dir = _get_daemon_home_dir()
@@ -267,8 +267,8 @@ async def _build_state(self, session_manager: Any) -> DaemonState:
e,
)
- # Canonical internal uses connected_peers; state model uses num_peers
- num_peers = status.get("connected_peers", status.get("num_peers", 0))
+ # Canonical internal keys are `connected_peers` / `active_peers`.
+ num_peers = status.get("connected_peers", 0)
torrents[info_hash_hex] = TorrentState(
info_hash=info_hash_hex,
name=status.get("name", "Unknown"),
diff --git a/ccbt/discovery/dht.py b/ccbt/discovery/dht.py
index 198db9b1..2c4bee56 100644
--- a/ccbt/discovery/dht.py
+++ b/ccbt/discovery/dht.py
@@ -20,6 +20,7 @@
from ccbt.config.config import get_config
from ccbt.core.bencode import BencodeDecoder, BencodeEncoder
from ccbt.models import PeerInfo
+from ccbt.utils.shutdown import is_shutting_down
# Error message constants
_ERROR_DHT_TRANSPORT_NOT_INITIALIZED = "DHT transport is not initialized"
@@ -112,6 +113,7 @@ class DHTToken:
token: bytes
info_hash: bytes
+ node_addr: tuple[str, int] = ("", 0)
created_time: float = field(default_factory=time.time)
expires_time: float = field(
default_factory=lambda: time.time() + 900.0,
@@ -468,7 +470,7 @@ def __init__(
# Routing table
self.routing_table = KademliaRoutingTable(self.node_id)
- # Bootstrap nodes - CRITICAL FIX: Use config instead of hardcoded defaults
+ # Bootstrap nodes - Note: Use config instead of hardcoded defaults
# Parse bootstrap nodes from config (format: "host:port")
# Initialize logger first for error reporting
self.logger = logging.getLogger(__name__)
@@ -509,6 +511,42 @@ def __init__(
# Bootstrap node performance tracking
# Maps (host, port) -> performance metrics
self.bootstrap_performance: dict[tuple[str, int], dict[str, Any]] = {}
+ self._bootstrap_attempt_failures: dict[tuple[str, int], int] = {}
+ self._bootstrap_attempt_timestamps: dict[tuple[str, int], float] = {}
+ discovery_cfg = getattr(self.config, "discovery", None)
+ self._dht_bootstrap_retries_max = int(
+ getattr(discovery_cfg, "dht_bootstrap_retries_max", 3)
+ )
+ self._dht_bootstrap_memo_ttl_s = float(
+ getattr(discovery_cfg, "dht_bootstrap_memo_ttl_s", 120.0)
+ )
+ self._dht_bootstrap_timeout_s = float(
+ getattr(discovery_cfg, "dht_bootstrap_timeout_s", 30.0) or 30.0
+ )
+ # Host-level DNS failure backoff (memoized per hostname; monotonic deadlines)
+ self._dht_dns_host_backoff_until: dict[str, float] = {}
+ self._dht_dns_host_fail_streak: dict[str, int] = {}
+ self._dht_dns_host_backoff_initial_s = float(
+ getattr(discovery_cfg, "dht_dns_host_backoff_initial_s", 2.0) or 2.0
+ )
+ self._dht_dns_host_backoff_max_s = float(
+ getattr(discovery_cfg, "dht_dns_host_backoff_max_s", 120.0) or 120.0
+ )
+ self._dht_dns_host_backoff_multiplier = float(
+ getattr(discovery_cfg, "dht_dns_host_backoff_multiplier", 2.0) or 2.0
+ )
+ self.bootstrap_success_count = 0
+ self.bootstrap_failure_count = 0
+ self.last_bootstrap_reason = "not_started"
+ self.last_bootstrap_failure_reason = ""
+ self.last_zero_node_lookup_at = 0.0
+ self.last_bootstrap_state = "idle"
+ self.last_lookup_state = "idle"
+ self._empty_table_rebootstrap_attempts = 0
+ self._max_empty_table_rebootstrap_attempts = 3
+ self._last_empty_table_rebootstrap_at = 0.0
+ self._empty_table_rebootstrap_backoff = 1.0
+ self._zero_node_rebootstrap_task: Optional[asyncio.Task[None]] = None
# Pending queries
self.pending_queries: dict[bytes, asyncio.Future] = {}
@@ -521,13 +559,19 @@ def __init__(
# Adaptive timeout calculator (lazy initialization)
self._timeout_calculator: Optional[Any] = None
- # Tokens for announce_peer
- self.tokens: dict[bytes, DHTToken] = {}
+ # Tokens for announce_peer, keyed by (info_hash, node_addr) or legacy info_hash
+ self.tokens: dict[Union[bytes, tuple[bytes, tuple[str, int]]], DHTToken] = {}
self.token_secret = os.urandom(20)
# Background tasks
self._refresh_task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None
+ self._bootstrap_task: Optional[asyncio.Task] = None
+ self._bootstrap_lock = asyncio.Lock()
+ # Wall-clock deadline (time.time()) for in-flight bootstrap; used to clamp
+ # per-query and DNS timeouts so desperation-mode DHT timeouts cannot exhaust
+ # the whole bootstrap budget on a single operation.
+ self._bootstrap_query_deadline: Optional[float] = None
# Callbacks with info_hash filtering
# Maps info_hash -> list of callbacks, or None for global callbacks
@@ -535,9 +579,17 @@ def __init__(
self.peer_callbacks_by_hash: dict[
bytes, list[Callable[[list[tuple[str, int]]], None]]
] = {}
+ self.callback_metrics: dict[str, int] = {
+ "peers_found_without_callbacks": 0,
+ "peers_delivered_to_callbacks": 0,
+ "callback_exceptions": 0,
+ "callbacks_registered": 0,
+ }
# BEP 27: Callback to check if a torrent is private
self.is_private_torrent: Optional[Callable[[bytes], bool]] = None
+ # Authenticated swarms discovery policy callback: return True when DHT should be suppressed
+ self.is_swarm_discovery_disabled: Optional[Callable[[bytes], bool]] = None
self._xet_mutable_store: dict[bytes, bytes] = {}
# BEP 44: storage write tokens from get responses: key -> ([(token, addr), ...], expires_at)
self._storage_tokens: dict[
@@ -575,7 +627,7 @@ async def start(self) -> None:
local_addr=(self.bind_ip, self.bind_port),
)
except OSError as e:
- # CRITICAL FIX: Enhanced port conflict error handling
+ # Note: Enhanced port conflict error handling
error_code = e.errno if hasattr(e, "errno") else None
import sys
@@ -635,8 +687,8 @@ async def start(self) -> None:
self._refresh_task = asyncio.create_task(self._refresh_loop())
self._cleanup_task = asyncio.create_task(self._cleanup_loop())
- # Bootstrap
- await self._bootstrap()
+ # Bootstrap in background so daemon startup is not blocked (up to 30s when nodes unreachable)
+ self._bootstrap_task = asyncio.create_task(self._bootstrap())
self.logger.info("DHT client started on %s:%s", self.bind_ip, self.bind_port)
@@ -650,6 +702,13 @@ async def stop(self) -> None:
4. Clear socket reference
5. Clear transport reference
"""
+ # Release in-flight query waiters immediately (avoids 60s tails during shutdown)
+ if self.pending_queries:
+ for _tid, fut in list(self.pending_queries.items()):
+ if not fut.done():
+ fut.cancel()
+ self.pending_queries.clear()
+
if self._refresh_task:
self._refresh_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
@@ -660,10 +719,24 @@ async def stop(self) -> None:
with contextlib.suppress(asyncio.CancelledError):
await self._cleanup_task
+ if self._bootstrap_task:
+ self._bootstrap_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._bootstrap_task
+ self._bootstrap_task = None
+ if (
+ self._zero_node_rebootstrap_task
+ and not self._zero_node_rebootstrap_task.done()
+ ):
+ self._zero_node_rebootstrap_task.cancel()
+ with contextlib.suppress(asyncio.CancelledError):
+ await self._zero_node_rebootstrap_task
+ self._zero_node_rebootstrap_task = None
+
# Proper cleanup order: close transport first, then handle socket
if self.transport:
self.transport.close()
- # CRITICAL FIX: Wait for transport to fully close (Windows timing issue)
+ # Note: Wait for transport to fully close (Windows timing issue)
# On Windows, UDP sockets may not be immediately released after close()
# This prevents "WinError 10048: Only one usage of each socket address" errors
import sys
@@ -706,11 +779,19 @@ async def stop(self) -> None:
self.logger.info("DHT client stopped")
- async def wait_for_bootstrap(self, timeout: float = 10.0) -> bool:
+ async def wait_for_bootstrap(
+ self,
+ timeout: float = 10.0,
+ *,
+ min_nodes: int = 8,
+ allow_partial: bool = False,
+ ) -> bool:
"""Wait for DHT bootstrap to complete.
Args:
timeout: Maximum time to wait for bootstrap in seconds
+ min_nodes: Minimum routing nodes required for operational readiness
+ allow_partial: If True, return success when any routing node exists
Returns:
True if bootstrap completed, False if timeout
@@ -720,61 +801,317 @@ async def wait_for_bootstrap(self, timeout: float = 10.0) -> bool:
import time
start_time = time.time()
+ min_nodes = max(1, int(min_nodes))
# Check if we have enough nodes in routing table (bootstrap is complete)
while time.time() - start_time < timeout:
- if len(self.routing_table.nodes) >= 8:
+ if len(self.routing_table.nodes) >= min_nodes:
return True
await asyncio.sleep(0.1)
- # Return True if we have any nodes (partial bootstrap), False otherwise
- return len(self.routing_table.nodes) > 0
+ if allow_partial:
+ # Return True if we have any nodes (partial bootstrap), False otherwise
+ return len(self.routing_table.nodes) > 0
+ return len(self.routing_table.nodes) >= min_nodes
- async def _bootstrap(self) -> None:
+ async def _bootstrap(self, reason: str = "bootstrap") -> None:
"""Bootstrap the DHT by finding initial nodes."""
- self.logger.info("Bootstrapping DHT...")
+ async with self._bootstrap_lock:
+ await self._bootstrap_core(reason)
- # CRITICAL FIX: Add overall timeout to bootstrap process (30 seconds max)
- # This prevents hanging indefinitely if all bootstrap nodes are unreachable
- bootstrap_timeout = 30.0
+ async def _bootstrap_core(self, reason: str = "bootstrap") -> None:
+ self.last_bootstrap_reason = reason
+ self.last_bootstrap_failure_reason = ""
+ self.last_bootstrap_state = f"starting:{reason}"
+ self.logger.info("Bootstrapping DHT...")
+ self.last_zero_node_lookup_at = 0.0
start_time = time.time()
+ self._prune_bootstrap_attempt_state(start_time)
- # Try to find nodes from bootstrap servers
- for host, port in self.bootstrap_nodes:
- # Check if we've exceeded overall timeout
- if time.time() - start_time > bootstrap_timeout:
- self.logger.warning(
- "Bootstrap timeout (%.1fs) - continuing with %d nodes",
- bootstrap_timeout,
- len(self.routing_table.nodes),
- )
- break
+ # Overall bootstrap wall clock (config: discovery.dht_bootstrap_timeout_s)
+ bootstrap_timeout = self._dht_bootstrap_timeout_s
+ self._bootstrap_query_deadline = start_time + bootstrap_timeout
- if not await self._bootstrap_step(host, port):
- continue
+ try:
+ # Try to find nodes from bootstrap servers
+ for host, port in self.bootstrap_nodes:
+ # Check if we've exceeded overall timeout
+ if time.time() - start_time > bootstrap_timeout:
+ self.logger.warning(
+ "Bootstrap timeout (%.1fs) - continuing with %d nodes",
+ bootstrap_timeout,
+ len(self.routing_table.nodes),
+ )
+ self.last_bootstrap_failure_reason = "bootstrap_timeout"
+ self.last_bootstrap_state = "failed:bootstrap_timeout"
+ break
- # If we have enough nodes, we can stop early
- if len(self.routing_table.nodes) >= 8:
- self.logger.info(
- "Bootstrap complete: found %d nodes", len(self.routing_table.nodes)
+ if not await self._bootstrap_step(host, port):
+ continue
+
+ # If we have enough nodes, we can stop early
+ if len(self.routing_table.nodes) >= 8:
+ self.bootstrap_success_count += 1
+ self.logger.info(
+ "Bootstrap complete: found %d nodes",
+ len(self.routing_table.nodes),
+ )
+ return
+
+ # If we still don't have enough nodes, try to find more (with timeout check)
+ if (
+ len(self.routing_table.nodes) < 8
+ and time.time() - start_time < bootstrap_timeout
+ ):
+ try:
+ await asyncio.wait_for(
+ self._refresh_routing_table(),
+ timeout=max(
+ 1.0, bootstrap_timeout - (time.time() - start_time)
+ ),
+ )
+ except asyncio.TimeoutError:
+ self.logger.debug("Refresh routing table timeout during bootstrap")
+ self.last_bootstrap_failure_reason = "routing_refresh_timeout"
+
+ if len(self.routing_table.nodes) > 0:
+ self.bootstrap_success_count += 1
+ self._empty_table_rebootstrap_attempts = 0
+ self._empty_table_rebootstrap_backoff = 1.0
+ self.last_bootstrap_state = "succeeded"
+ else:
+ self.bootstrap_failure_count += 1
+ if not self.last_bootstrap_failure_reason:
+ self.last_bootstrap_failure_reason = "no_nodes_discovered"
+ self.last_bootstrap_state = (
+ f"failed:{self.last_bootstrap_failure_reason}"
)
- return
+ except asyncio.CancelledError:
+ if not self.last_bootstrap_failure_reason:
+ self.last_bootstrap_failure_reason = "bootstrap_cancelled_or_timeout"
+ raise
+ finally:
+ self._bootstrap_query_deadline = None
+
+ self.logger.info(
+ "Bootstrap completed with %d nodes", len(self.routing_table.nodes)
+ )
+
+ async def rebootstrap(self) -> bool:
+ """Retry bootstrap using ranked bootstrap nodes.
+
+ Returns:
+ True if at least one node is present after rebootstrap, False otherwise.
+ """
+ ranked_nodes = self._rank_bootstrap_nodes(self.bootstrap_nodes)
+ original_nodes = self.bootstrap_nodes
+ if ranked_nodes:
+ self.bootstrap_nodes = ranked_nodes
+ try:
+ await self._bootstrap(reason="rebootstrap")
+ finally:
+ self.bootstrap_nodes = original_nodes
+ return len(self.routing_table.nodes) > 0
+
+ def _can_attempt_bootstrap_node(self, bootstrap_key: tuple[str, int]) -> bool:
+ """Return whether a bootstrap node can be retried now."""
+ now = time.time()
+ failure_count = self._bootstrap_attempt_failures.get(bootstrap_key, 0)
+ last_failure = self._bootstrap_attempt_timestamps.get(bootstrap_key, 0.0)
+ if failure_count < self._dht_bootstrap_retries_max:
+ return True
+ if now - last_failure > self._dht_bootstrap_memo_ttl_s:
+ self._bootstrap_attempt_failures.pop(bootstrap_key, None)
+ self._bootstrap_attempt_timestamps.pop(bootstrap_key, None)
+ return True
+ return False
+
+ def _mark_bootstrap_attempt_failed(self, bootstrap_key: tuple[str, int]) -> None:
+ """Track failed bootstrap attempts for deduplication."""
+ self._bootstrap_attempt_failures[bootstrap_key] = (
+ self._bootstrap_attempt_failures.get(bootstrap_key, 0) + 1
+ )
+ self._bootstrap_attempt_timestamps[bootstrap_key] = time.time()
+
+ def _mark_bootstrap_attempt_succeeded(self, bootstrap_key: tuple[str, int]) -> None:
+ """Reset cached bootstrap failure state after a successful bootstrap."""
+ self._bootstrap_attempt_failures.pop(bootstrap_key, None)
+ self._bootstrap_attempt_timestamps.pop(bootstrap_key, None)
+
+ def _prune_bootstrap_attempt_state(self, now: Optional[float] = None) -> None:
+ """Prune stale bootstrap failure records."""
+ current_time = now if now is not None else time.time()
+ stale_keys = [
+ bootstrap_key
+ for bootstrap_key, last_failure in self._bootstrap_attempt_timestamps.items()
+ if current_time - last_failure > self._dht_bootstrap_memo_ttl_s
+ ]
+ for bootstrap_key in stale_keys:
+ self._bootstrap_attempt_failures.pop(bootstrap_key, None)
+ self._bootstrap_attempt_timestamps.pop(bootstrap_key, None)
+
+ @staticmethod
+ def _normalize_dht_dns_host(host: str) -> str:
+ """Normalize hostname for DNS backoff keys (case-insensitive)."""
+ return host.strip().lower()
+
+ def _is_dns_host_in_backoff(self, host: str) -> bool:
+ """Return True if this hostname should skip resolver calls until backoff expires."""
+ host_key = self._normalize_dht_dns_host(host)
+ until = self._dht_dns_host_backoff_until.get(host_key)
+ if until is None:
+ return False
+ now_m = time.monotonic()
+ if now_m >= until:
+ self._dht_dns_host_backoff_until.pop(host_key, None)
+ return False
+ return True
+
+ def _dns_host_backoff_remaining_s(self, host: str) -> float:
+ """Seconds remaining in host-level DNS backoff, or 0.0 if none."""
+ host_key = self._normalize_dht_dns_host(host)
+ until = self._dht_dns_host_backoff_until.get(host_key)
+ if until is None:
+ return 0.0
+ return max(0.0, until - time.monotonic())
+
+ def _record_dns_host_failure(self, host: str) -> None:
+ """Apply exponential per-host backoff after DNS timeout or resolver error."""
+ host_key = self._normalize_dht_dns_host(host)
+ streak = self._dht_dns_host_fail_streak.get(host_key, 0) + 1
+ self._dht_dns_host_fail_streak[host_key] = streak
+ initial = max(0.5, self._dht_dns_host_backoff_initial_s)
+ mult = max(1.0, self._dht_dns_host_backoff_multiplier)
+ max_s = max(initial, self._dht_dns_host_backoff_max_s)
+ delay = min(max_s, initial * (mult ** (streak - 1)))
+ self._dht_dns_host_backoff_until[host_key] = time.monotonic() + delay
+ self.logger.debug(
+ "DHT DNS host backoff scheduled: host=%s streak=%d delay=%.1fs",
+ host_key,
+ streak,
+ delay,
+ )
+
+ def _clear_dns_host_backoff(self, host: str) -> None:
+ """Clear memoized DNS failure state after a successful resolve."""
+ host_key = self._normalize_dht_dns_host(host)
+ self._dht_dns_host_backoff_until.pop(host_key, None)
+ self._dht_dns_host_fail_streak.pop(host_key, None)
+
+ def _empty_routing_operational_context(self) -> str:
+ """Compact diagnostics for empty-routing warnings (bind, DHT IP prefs, bootstrap)."""
+ discovery = getattr(self.config, "discovery", None)
+ ipv6 = bool(getattr(discovery, "dht_enable_ipv6", False))
+ prefer6 = bool(getattr(discovery, "dht_prefer_ipv6", False))
+ fr = self.last_bootstrap_failure_reason or "unset"
+ return (
+ f"bind={self.bind_ip}:{self.bind_port} "
+ f"dht_ipv6_enabled={ipv6} dht_prefer_ipv6={prefer6} "
+ f"bootstrap_nodes={len(self.bootstrap_nodes)} "
+ f"last_bootstrap_failure_reason={fr!r}"
+ )
+
+ def _log_empty_routing_warning(self, summary: str) -> None:
+ """Emit one WARNING line with operational context for empty routing table."""
+ self.logger.warning("%s %s", summary, self._empty_routing_operational_context())
- # If we still don't have enough nodes, try to find more (with timeout check)
+ def _schedule_zero_node_rebootstrap(
+ self, reason: str = "empty_routing_table"
+ ) -> bool:
+ """Schedule a bounded rebootstrap when routing table is empty."""
+ now = time.monotonic()
if (
- len(self.routing_table.nodes) < 8
- and time.time() - start_time < bootstrap_timeout
+ self._zero_node_rebootstrap_task is not None
+ and not self._zero_node_rebootstrap_task.done()
):
- try:
- await asyncio.wait_for(
- self._refresh_routing_table(),
- timeout=max(1.0, bootstrap_timeout - (time.time() - start_time)),
+ with contextlib.suppress(Exception):
+ from ccbt.monitoring import get_metrics_collector
+
+ get_metrics_collector().increment_counter(
+ "dht_zero_node_rebootstrap_suppressed_total"
)
- except asyncio.TimeoutError:
- self.logger.debug("Refresh routing table timeout during bootstrap")
+ self.logger.debug(
+ "DHT empty routing rebootstrap already in flight, suppressing duplicate (%s)",
+ reason,
+ )
+ self.last_bootstrap_state = "suppressed:rebootstrap_inflight"
+ self.last_bootstrap_failure_reason = (
+ f"empty_table_rebootstrap_suppressed:inflight:{reason}"
+ )
+ return False
+ if (
+ self._empty_table_rebootstrap_attempts
+ >= self._max_empty_table_rebootstrap_attempts
+ ):
+ with contextlib.suppress(Exception):
+ from ccbt.monitoring import get_metrics_collector
- self.logger.info(
- "Bootstrap completed with %d nodes", len(self.routing_table.nodes)
+ get_metrics_collector().increment_counter(
+ "dht_zero_node_rebootstrap_suppressed_total"
+ )
+ self.logger.debug(
+ "DHT empty routing rebootstrap suppressed after %d attempts: %s",
+ self._empty_table_rebootstrap_attempts,
+ reason,
+ )
+ self.last_bootstrap_state = "suppressed:rebootstrap_limit_reached"
+ self.last_bootstrap_failure_reason = (
+ f"empty_table_rebootstrap_suppressed:limit:{reason}"
+ )
+ return False
+
+ cooldown = self._empty_table_rebootstrap_backoff
+ if now - self._last_empty_table_rebootstrap_at < cooldown:
+ with contextlib.suppress(Exception):
+ from ccbt.monitoring import get_metrics_collector
+
+ get_metrics_collector().increment_counter(
+ "dht_zero_node_rebootstrap_suppressed_total"
+ )
+ self.logger.debug(
+ "DHT empty routing rebootstrap skipped due cooldown %.1fs (%s)",
+ cooldown - (now - self._last_empty_table_rebootstrap_at),
+ reason,
+ )
+ self.last_bootstrap_state = "suppressed:empty_table_cooldown"
+ self.last_bootstrap_failure_reason = (
+ f"empty_table_rebootstrap_suppressed:cooldown:{reason}"
+ )
+ return False
+
+ self._last_empty_table_rebootstrap_at = now
+ self._empty_table_rebootstrap_attempts += 1
+ self._empty_table_rebootstrap_backoff = min(
+ self._empty_table_rebootstrap_backoff * 2.0, 60.0
+ )
+ self.last_bootstrap_state = "scheduled:empty_table_rebootstrap"
+ self.last_bootstrap_failure_reason = (
+ f"empty_table_rebootstrap_scheduled:{reason}"
+ )
+ self.logger.warning(
+ "DHT bootstrap retries: scheduling bounded rebootstrap %d/%d for %s",
+ self._empty_table_rebootstrap_attempts,
+ self._max_empty_table_rebootstrap_attempts,
+ reason,
)
+ with contextlib.suppress(Exception):
+ from ccbt.monitoring import get_metrics_collector
+
+ get_metrics_collector().increment_counter("dht_zero_node_rebootstrap_total")
+
+ async def _run() -> None:
+ try:
+ await self.rebootstrap()
+ except Exception as exc: # pragma: no cover - defensive logging
+ self.logger.debug("DHT scheduled rebootstrap failed: %s", exc)
+ self.last_bootstrap_state = f"failed:{type(exc).__name__}"
+ finally:
+ self._zero_node_rebootstrap_task = None
+
+ self._zero_node_rebootstrap_task = asyncio.create_task(
+ _run(),
+ name=f"dht-zero-node-rebootstrap:{reason}",
+ )
+ return True
async def _bootstrap_step(self, host: str, port: int) -> bool:
"""Attempt to bootstrap from a single host:port. Returns False on error.
@@ -782,10 +1119,45 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
Tracks performance for dynamic bootstrap node selection.
"""
bootstrap_key = (host, port)
+ if not self._can_attempt_bootstrap_node(bootstrap_key):
+ self.logger.debug(
+ "Skipping bootstrap node %s:%s due recent duplicate failures",
+ host,
+ port,
+ )
+ self.last_bootstrap_failure_reason = (
+ f"bootstrap_retry_suppressed:{host}:{port}"
+ )
+ return False
+ if self._is_dns_host_in_backoff(host):
+ remaining = self._dns_host_backoff_remaining_s(host)
+ self.last_bootstrap_failure_reason = f"dns_host_backoff:{host}"
+ self.logger.debug(
+ "Skipping bootstrap DNS for %s:%s: host-level backoff active (%.1fs remaining)",
+ host,
+ port,
+ remaining,
+ )
+ return False
start_time = time.time()
+ dns_cap = 5.0
+ b_deadline = self._bootstrap_query_deadline
+ if b_deadline is not None:
+ rem = b_deadline - time.time()
+ if rem <= 0:
+ self.last_bootstrap_failure_reason = (
+ "bootstrap_wall_exhausted:before_dns"
+ )
+ self.logger.debug(
+ "Skipping bootstrap DNS for %s:%s: bootstrap wall clock exhausted",
+ host,
+ port,
+ )
+ return False
+ dns_cap = min(5.0, max(0.5, rem))
try:
- # CRITICAL FIX: Use async DNS resolution with timeout to prevent hanging
+ # Note: Use async DNS resolution with timeout to prevent hanging
# socket.gethostbyname() is blocking and can hang indefinitely
try:
# Use asyncio.to_thread() to run blocking DNS resolution in thread pool
@@ -800,7 +1172,7 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
family=socket.AF_INET,
type=socket.SOCK_DGRAM,
),
- timeout=5.0,
+ timeout=dns_cap,
)
else:
# Python 3.7-3.8: use run_in_executor
@@ -814,16 +1186,25 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
socket.AF_INET,
socket.SOCK_DGRAM,
),
- timeout=5.0,
+ timeout=dns_cap,
)
# Extract IPv4 address from first result
addr = (addr_info[0][4][0], port)
+ self._clear_dns_host_backoff(host)
except asyncio.TimeoutError:
+ self.last_bootstrap_failure_reason = f"dns_timeout:{host}:{port}"
+ self._record_dns_host_failure(host)
+ self._mark_bootstrap_attempt_failed(bootstrap_key)
self.logger.debug(
"DNS resolution timeout for bootstrap node %s:%s", host, port
)
return False
except Exception as dns_error:
+ self.last_bootstrap_failure_reason = (
+ f"dns_failed:{host}:{port}:{type(dns_error).__name__}"
+ )
+ self._record_dns_host_failure(host)
+ self._mark_bootstrap_attempt_failed(bootstrap_key)
self.logger.debug(
"DNS resolution failed for bootstrap node %s:%s: %s",
host,
@@ -837,6 +1218,7 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
# Track successful bootstrap
response_time = time.time() - start_time
+ self._mark_bootstrap_attempt_succeeded(bootstrap_key)
if bootstrap_key not in self.bootstrap_performance:
self.bootstrap_performance[bootstrap_key] = {
"success_count": 0,
@@ -844,11 +1226,13 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
"response_times": [],
"last_success": 0.0,
"last_failure": 0.0,
+ "last_failure_reason": "",
}
perf = self.bootstrap_performance[bootstrap_key]
perf["success_count"] += 1
perf["last_success"] = time.time()
+ perf["last_failure_reason"] = ""
perf["response_times"].append(response_time)
if len(perf["response_times"]) > 10:
perf["response_times"].pop(0)
@@ -856,6 +1240,10 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
return True
except Exception as e:
self.logger.debug("Bootstrap failed for %s:%s: %s", host, port, e)
+ self.last_bootstrap_failure_reason = (
+ f"bootstrap_failed:{host}:{port}:{type(e).__name__}"
+ )
+ self._mark_bootstrap_attempt_failed(bootstrap_key)
# Track failed bootstrap
response_time = time.time() - start_time
@@ -866,11 +1254,13 @@ async def _bootstrap_step(self, host: str, port: int) -> bool:
"response_times": [],
"last_success": 0.0,
"last_failure": 0.0,
+ "last_failure_reason": "",
}
perf = self.bootstrap_performance[bootstrap_key]
perf["failure_count"] += 1
perf["last_failure"] = time.time()
+ perf["last_failure_reason"] = type(e).__name__
perf["response_times"].append(response_time)
if len(perf["response_times"]) > 10:
perf["response_times"].pop(0)
@@ -1401,15 +1791,74 @@ async def get_peers(
"Skipping DHT get_peers for private torrent %s (BEP 27)",
info_hash.hex()[:8],
)
+ self.last_lookup_state = "skipped_private"
+ return []
+ if self.is_swarm_discovery_disabled and self.is_swarm_discovery_disabled(
+ info_hash
+ ):
+ self.logger.debug(
+ "Skipping DHT get_peers for authenticated strict discovery mode %s",
+ info_hash.hex()[:8],
+ )
+ try:
+ from ccbt.monitoring import get_metrics_collector
+ from ccbt.monitoring.metrics_collector import MetricLabel
+ from ccbt.security import SWARM_AUTH_DISCOVERY_SUPPRESSED_TOTAL
+
+ get_metrics_collector().increment_counter(
+ SWARM_AUTH_DISCOVERY_SUPPRESSED_TOTAL,
+ labels=[
+ MetricLabel(name="mode", value="strict"),
+ MetricLabel(name="component", value="dht"),
+ ],
+ )
+ except Exception: # pragma: no cover - optional metrics path
+ pass
+ self.last_lookup_state = "swarm_discovery_disabled"
return []
# Use a set to track unique peers (deduplication)
peers_set: set[tuple[str, int]] = set()
queried_nodes: set[bytes] = set()
+ self.last_lookup_state = "started"
# Get initial k closest nodes
closest_nodes = self.routing_table.get_closest_nodes(info_hash, k)
closest_set: set[DHTNode] = set(closest_nodes)
+ if not closest_set:
+ self.last_lookup_state = "empty_routing_table"
+ self.last_zero_node_lookup_at = time.time()
+ with contextlib.suppress(Exception):
+ from ccbt.monitoring import get_metrics_collector
+
+ get_metrics_collector().increment_counter(
+ "dht_empty_routing_lookups_total"
+ )
+ self._log_empty_routing_warning(
+ f"DHT lookup for {info_hash.hex()[:8]} cannot start because the routing table "
+ f"is empty (queried 0 nodes). Bootstrap is missing, blocked, or has not completed yet."
+ )
+ retry_scheduled = self._schedule_zero_node_rebootstrap(
+ reason=f"get_peers:{info_hash.hex()[:8]}"
+ )
+ self._last_query_metrics = {
+ "duration": 0.0,
+ "peers_found": 0,
+ "depth": 0,
+ "nodes_queried": 0,
+ "alpha": alpha,
+ "k": k,
+ "max_depth": max_depth if max_depth is not None else 10,
+ "empty_result_reason": "empty_routing_table",
+ "empty_result_reason_code": "bootstrap_not_operational",
+ "zero_node_lookup": True,
+ "lookup_state": self.last_lookup_state,
+ "empty_table_retry_scheduled": retry_scheduled,
+ "empty_table_retry_reason_code": (
+ "scheduled" if retry_scheduled else "suppressed"
+ ),
+ }
+ return []
# Track query depth for logging
query_depth = 0
@@ -1520,7 +1969,7 @@ async def get_peers(
if peer_addr not in peers_set:
peers_set.add(peer_addr)
- # CRITICAL FIX: Invoke callbacks immediately when peers are found
+ # Note: Invoke callbacks immediately when peers are found
# This ensures peers are connected as soon as they're discovered
# rather than waiting until the entire query completes
try:
@@ -1593,7 +2042,7 @@ async def get_peers(
found_closer_nodes = True
elif closest_set:
# Check if this node is closer than the farthest node in closest_set
- # CRITICAL FIX: Use list() to avoid set modification during iteration
+ # Note: Use list() to avoid set modification during iteration
farthest_node = max(
list(closest_set),
key=lambda n: self.routing_table.distance(
@@ -1606,7 +2055,7 @@ async def get_peers(
if new_distance < farthest_distance:
# Replace farthest with this closer node
- # CRITICAL FIX: Check if node still exists before removing (race condition fix)
+ # Note: Check if node still exists before removing (race condition fix)
closest_set.discard(farthest_node)
closest_set.add(new_node)
found_closer_nodes = True
@@ -1651,7 +2100,15 @@ async def get_peers(
# Store token for announce_peer
token = r.get(b"token")
if token:
- self.tokens[info_hash] = DHTToken(token, info_hash)
+ token_entry = DHTToken(
+ token,
+ info_hash,
+ (node.ip, node.port),
+ )
+ self.tokens[(info_hash, (node.ip, node.port))] = token_entry
+ # Keep a legacy info_hash alias for compatibility with older
+ # diagnostics/tests while scoped tokens remain authoritative.
+ self.tokens[info_hash] = token_entry
# Stop if we have enough peers
if len(peers_set) >= max_peers:
@@ -1718,7 +2175,7 @@ async def get_peers(
# Notify callbacks with info_hash filtering (even if peers list is empty,
# callbacks might have been invoked during the query via incoming messages)
- # CRITICAL FIX: Always invoke callbacks with final peer list, even if empty
+ # Note: Always invoke callbacks with final peer list, even if empty
# This ensures callbacks are notified when query completes
# Also invoke with all discovered peers (not just new ones) to ensure all peers are processed
if peers:
@@ -1733,6 +2190,20 @@ async def get_peers(
"DHT get_peers query completed: no peers found for info_hash %s (callbacks may have been invoked during query)",
info_hash.hex()[:16],
)
+ if nodes_queried_count > 0:
+ routing_table_size = len(self.routing_table.nodes)
+ self.logger.info(
+ "DHT get_peers for %s returned 0 peers after querying %d nodes (depth=%d, routing table=%d nodes). This usually indicates a thin swarm or peers not currently announcing.",
+ info_hash.hex()[:16],
+ nodes_queried_count,
+ query_depth,
+ routing_table_size,
+ )
+ else:
+ self.logger.debug(
+ "DHT get_peers for %s returned 0 peers because no nodes could be queried.",
+ info_hash.hex()[:16],
+ )
# Emit DHT query complete event
try:
@@ -1778,7 +2249,25 @@ async def get_peers(
"alpha": alpha,
"k": k,
"max_depth": effective_max_depth,
+ "empty_result_reason": (
+ "query_zero_nodes" if nodes_queried_count == 0 else "empty_peer_set"
+ ),
+ "zero_node_lookup": len(queried_nodes) == 0,
}
+ if len(queried_nodes) == 0:
+ self.last_lookup_state = "query_zero_nodes"
+ elif not peers:
+ self.last_lookup_state = "empty_peer_set"
+ else:
+ self.last_lookup_state = "peers_found"
+
+ self._last_query_metrics["lookup_state"] = self.last_lookup_state
+ if len(queried_nodes) == 0:
+ self.last_zero_node_lookup_at = time.time()
+ self.logger.warning(
+ "DHT lookup for %s completed with queried 0 nodes. Treating this as bootstrap-missing rather than a normal empty peer result.",
+ info_hash.hex()[:8],
+ )
return peers
@@ -1807,29 +2296,73 @@ async def announce_peer(self, info_hash: bytes, port: int) -> int:
info_hash.hex()[:8],
)
return 0
+ if self.is_swarm_discovery_disabled and self.is_swarm_discovery_disabled(
+ info_hash
+ ):
+ self.logger.debug(
+ "Skipping DHT announce_peer for authenticated strict discovery mode %s",
+ info_hash.hex()[:8],
+ )
+ try:
+ from ccbt.monitoring import get_metrics_collector
+ from ccbt.monitoring.metrics_collector import MetricLabel
+ from ccbt.security import SWARM_AUTH_DISCOVERY_SUPPRESSED_TOTAL
+
+ get_metrics_collector().increment_counter(
+ SWARM_AUTH_DISCOVERY_SUPPRESSED_TOTAL,
+ labels=[
+ MetricLabel(name="mode", value="strict"),
+ MetricLabel(name="component", value="dht"),
+ ],
+ )
+ except Exception: # pragma: no cover - optional metrics path
+ pass
+ return 0
- # Get token for this info hash
- if info_hash not in self.tokens:
+ # Get token(s) for this info hash
+ token_entries = [
+ token
+ for token in self.tokens.values()
+ if getattr(token, "info_hash", None) == info_hash
+ ]
+ if not token_entries:
# Try to get token by doing a get_peers query
await self.get_peers(info_hash, 1)
+ token_entries = [
+ token
+ for token in self.tokens.values()
+ if getattr(token, "info_hash", None) == info_hash
+ ]
- if info_hash not in self.tokens:
+ if not token_entries:
self.logger.debug("No token available for %s", info_hash.hex())
return 0
- token = self.tokens[info_hash]
-
- # Check if token is still valid
- if time.time() > token.expires_time:
- del self.tokens[info_hash]
- return 0
-
# Find closest nodes to announce to
closest_nodes = self.routing_table.get_closest_nodes(info_hash, 8)
+ has_scoped_tokens = any(
+ isinstance(token_key, tuple)
+ and len(token_key) == 2
+ and token_key[0] == info_hash
+ for token_key in self.tokens
+ )
success_count = 0
for node in closest_nodes:
try:
+ token_key: Union[bytes, tuple[bytes, tuple[str, int]]] = (
+ info_hash,
+ (node.ip, node.port),
+ )
+ token = self.tokens.get(token_key)
+ if token is None and not has_scoped_tokens:
+ token_key = info_hash
+ token = self.tokens.get(info_hash)
+ if token is None:
+ continue
+ if time.time() > token.expires_time:
+ del self.tokens[token_key]
+ continue
response = await self._send_query(
(node.ip, node.port),
"announce_peer",
@@ -2106,6 +2639,16 @@ async def _send_query(
"""Send a DHT query and wait for response, tracking response time for quality metrics."""
# Calculate adaptive timeout based on peer health
query_timeout = self._calculate_adaptive_query_timeout()
+ if is_shutting_down():
+ query_timeout = min(query_timeout, 1.0)
+
+ b_deadline = self._bootstrap_query_deadline
+ if b_deadline is not None:
+ wall_remaining = b_deadline - time.time()
+ if wall_remaining <= 0:
+ query_timeout = min(query_timeout, 0.25)
+ else:
+ query_timeout = min(query_timeout, max(0.25, wall_remaining))
# Generate transaction ID
tid = os.urandom(2)
@@ -2215,16 +2758,18 @@ def _handle_request(self, message: dict[bytes, Any], addr: tuple[str, int]) -> N
t = message.get(b"t")
if not isinstance(a, dict) or t is None:
return
- if not get_config().discovery.dht_enable_storage:
- return
node_id = a.get(b"id")
if node_id is not None and len(node_id) == 20:
with contextlib.suppress(Exception):
self.routing_table.add_node(DHTNode(node_id, addr[0], addr[1]))
q = message.get(b"q")
if q == b"get":
+ if not get_config().discovery.dht_enable_storage:
+ return
self._handle_get_request(a, t, addr)
elif q == b"put":
+ if not get_config().discovery.dht_enable_storage:
+ return
self._handle_put_request(a, t, addr)
elif q == b"find_node":
self._handle_find_node_request(a, t, addr)
@@ -2564,6 +3109,14 @@ async def _refresh_loop(self) -> None:
# Calculate adaptive interval based on swarm health
interval = self._calculate_adaptive_interval()
await asyncio.sleep(interval)
+ if len(self.routing_table.nodes) == 0:
+ self._log_empty_routing_warning(
+ "DHT refresh detected empty routing table; triggering rebootstrap."
+ )
+ self._schedule_zero_node_rebootstrap(
+ reason="refresh_loop_empty_routing"
+ )
+ continue
await self._refresh_routing_table()
except asyncio.CancelledError:
break
@@ -2572,6 +3125,11 @@ async def _refresh_loop(self) -> None:
async def _refresh_routing_table(self) -> None:
"""Refresh routing table by finding nodes."""
+ if len(self.routing_table.nodes) == 0:
+ self.logger.debug(
+ "Skipping routing-table refresh because no nodes are currently available"
+ )
+ return
# Generate random target IDs to find nodes
for _ in range(8):
target_id = os.urandom(20)
@@ -2597,12 +3155,12 @@ async def _cleanup_old_data(self) -> None:
# Clean up expired tokens
expired_tokens = [
- info_hash
- for info_hash, token in self.tokens.items()
+ token_key
+ for token_key, token in self.tokens.items()
if current_time > token.expires_time
]
- for info_hash in expired_tokens:
- del self.tokens[info_hash]
+ for token_key in expired_tokens:
+ del self.tokens[token_key]
# Clean up expired BEP 44 storage tokens
expired_storage = [
@@ -2672,7 +3230,7 @@ def _invoke_peer_callbacks(
info_hash: Info hash to filter callbacks
"""
- # CRITICAL FIX: Add logging to verify callback invocations
+ # Note: Add logging to verify callback invocations
global_callback_count = len(self.peer_callbacks)
hash_specific_count = len(self.peer_callbacks_by_hash.get(info_hash, []))
@@ -2692,11 +3250,13 @@ def _invoke_peer_callbacks(
info_hash.hex()[:16] + "...",
len(peers),
)
+ self.callback_metrics["peers_found_without_callbacks"] += len(peers)
# Invoke global callbacks (no info_hash filtering)
for idx, callback in enumerate(self.peer_callbacks):
try:
callback(peers)
+ self.callback_metrics["peers_delivered_to_callbacks"] += len(peers)
self.logger.info(
"Invoked global DHT peer callback #%d for info_hash %s (%d peers)",
idx + 1,
@@ -2704,6 +3264,7 @@ def _invoke_peer_callbacks(
len(peers),
)
except Exception:
+ self.callback_metrics["callback_exceptions"] += 1
self.logger.exception(
"Peer callback error (global callback #%d, info_hash=%s)",
idx + 1,
@@ -2715,6 +3276,7 @@ def _invoke_peer_callbacks(
for idx, callback in enumerate(self.peer_callbacks_by_hash[info_hash]):
try:
callback(peers)
+ self.callback_metrics["peers_delivered_to_callbacks"] += len(peers)
self.logger.info(
"Invoked info_hash-specific DHT peer callback #%d for info_hash %s (%d peers)",
idx + 1,
@@ -2722,6 +3284,7 @@ def _invoke_peer_callbacks(
len(peers),
)
except Exception:
+ self.callback_metrics["callback_exceptions"] += 1
self.logger.exception(
"Peer callback error (info_hash=%s, callback #%d)",
info_hash.hex()[:8],
@@ -2746,6 +3309,7 @@ def add_peer_callback(
if info_hash not in self.peer_callbacks_by_hash:
self.peer_callbacks_by_hash[info_hash] = []
self.peer_callbacks_by_hash[info_hash].append(callback)
+ self.callback_metrics["callbacks_registered"] += 1
self.logger.debug(
"Registered DHT peer callback for info_hash %s (total callbacks for this hash: %d)",
info_hash.hex()[:8],
@@ -2753,6 +3317,7 @@ def add_peer_callback(
)
else:
self.peer_callbacks.append(callback)
+ self.callback_metrics["callbacks_registered"] += 1
self.logger.debug(
"Registered global DHT peer callback (total global callbacks: %d)",
len(self.peer_callbacks),
@@ -2789,6 +3354,16 @@ def get_stats(self) -> dict[str, Any]:
"routing_table": self.routing_table.get_stats(),
"tokens": len(self.tokens),
"pending_queries": len(self.pending_queries),
+ "empty_table_rebootstrap_attempts": self._empty_table_rebootstrap_attempts,
+ "max_empty_table_rebootstrap_attempts": self._max_empty_table_rebootstrap_attempts,
+ "empty_table_rebootstrap_backoff": self._empty_table_rebootstrap_backoff,
+ "bootstrap_success_count": self.bootstrap_success_count,
+ "bootstrap_failure_count": self.bootstrap_failure_count,
+ "last_bootstrap_reason": self.last_bootstrap_reason,
+ "last_bootstrap_failure_reason": self.last_bootstrap_failure_reason,
+ "last_bootstrap_state": self.last_bootstrap_state,
+ "last_lookup_state": self.last_lookup_state,
+ "last_zero_node_lookup_at": self.last_zero_node_lookup_at,
}
@@ -2822,6 +3397,12 @@ def get_dht_client() -> AsyncDHTClient:
async def init_dht() -> AsyncDHTClient:
"""Initialize global DHT client."""
+ global _dht_client
+ # Deterministic singleton lifecycle: if a global client already exists,
+ # stop it before replacing the reference.
+ if _dht_client is not None:
+ with contextlib.suppress(Exception):
+ await _dht_client.stop()
_dht_client = AsyncDHTClient()
await _dht_client.start()
return _dht_client
@@ -2835,5 +3416,8 @@ async def shutdown_dht() -> None:
"""Shutdown global DHT client."""
global _dht_client
if _dht_client:
- await _dht_client.stop()
+ client = _dht_client
+ # Always clear the singleton reference, even if stop fails, so
+ # subsequent tests/runs don't inherit stale state.
_dht_client = None
+ await client.stop()
diff --git a/ccbt/discovery/errors.py b/ccbt/discovery/errors.py
new file mode 100644
index 00000000..3fe79d0e
--- /dev/null
+++ b/ccbt/discovery/errors.py
@@ -0,0 +1,9 @@
+"""Discovery-layer exceptions shared across modules."""
+
+
+class SockRecvfromUnsupportedError(RuntimeError):
+ """Raised when the asyncio event loop does not provide sock_recvfrom."""
+
+ def __init__(self) -> None:
+ """Initialize with the standard error message."""
+ super().__init__("Event loop does not support sock_recvfrom")
diff --git a/ccbt/discovery/lpd.py b/ccbt/discovery/lpd.py
index 77accc57..ddbc52fc 100644
--- a/ccbt/discovery/lpd.py
+++ b/ccbt/discovery/lpd.py
@@ -10,7 +10,9 @@
import logging
import socket
import struct
-from typing import Callable, Optional
+from typing import Any, Callable, Optional, cast
+
+from ccbt.discovery.errors import SockRecvfromUnsupportedError
logger = logging.getLogger(__name__)
@@ -188,7 +190,10 @@ async def _listen(self) -> None:
break
# Wait for data
- data, addr = await loop.sock_recvfrom(self._socket, 1024)
+ sock_recvfrom = getattr(loop, "sock_recvfrom", None)
+ if sock_recvfrom is None:
+ raise SockRecvfromUnsupportedError
+ data, addr = await cast("Any", sock_recvfrom)(self._socket, 1024)
# Parse announcement
try:
diff --git a/ccbt/discovery/pex.py b/ccbt/discovery/pex.py
index 29b8771b..b7a77580 100644
--- a/ccbt/discovery/pex.py
+++ b/ccbt/discovery/pex.py
@@ -27,6 +27,7 @@ class PexPeer:
ip: str
port: int
peer_id: Optional[bytes] = None
+ flags: int = 0
added_time: float = field(default_factory=time.time)
source: str = "pex" # Source of this peer (pex, tracker, dht, etc.)
reliability_score: float = 1.0
@@ -128,7 +129,7 @@ async def stop(self) -> None:
async def _pex_loop(self) -> None:
"""Background task for PEX operations.
- CRITICAL FIX: Adaptive PEX interval based on peer count.
+ Note: Adaptive PEX interval based on peer count.
When peer count is low, exchange peers more frequently.
"""
base_pex_interval = (
@@ -138,7 +139,7 @@ async def _pex_loop(self) -> None:
while True: # pragma: no cover - Background loop, tested via cancellation
try:
- # CRITICAL FIX: Adaptive PEX interval based on connected peer count
+ # Note: Adaptive PEX interval based on connected peer count
# BEP 11 compliant: max 1 message per minute (60s), but allow 30s minimum for low peer counts
# If we have callback to get peer count, use it to adjust interval
if self.get_connected_peers_callback:
@@ -237,9 +238,19 @@ async def _send_pex_to_peer(self, session: PexSession) -> None:
added_success = False
dropped_success = False
+ from ccbt.extensions.pex import PeerExchange
+
# Send added peers if any
- added_count = len(added_peers) // 6 if added_peers else 0
+ added_count = 0
+ dropped_count = 0
+ pex_exchange = PeerExchange()
+
if added_peers:
+ with contextlib.suppress(Exception):
+ decoded = pex_exchange.decode_bep11_payload(added_peers)
+ added_count = len(decoded[0]) + len(decoded[1])
+ if added_count == 0:
+ added_count = len(added_peers) // 6
self.logger.info(
"PEX: Sending %d added peer(s) to %s",
added_count,
@@ -267,7 +278,12 @@ async def _send_pex_to_peer(self, session: PexSession) -> None:
session.consecutive_failures += 1
# Send dropped peers if any
- dropped_count = len(dropped_peers) // 6 if dropped_peers else 0
+ if dropped_peers:
+ with contextlib.suppress(Exception):
+ decoded = pex_exchange.decode_bep11_payload(dropped_peers)
+ dropped_count = len(decoded[2]) + len(decoded[3])
+ if dropped_count == 0:
+ dropped_count = len(dropped_peers) // 6
if dropped_peers:
self.logger.info(
"PEX: Sending %d dropped peer(s) to %s",
@@ -312,8 +328,8 @@ async def _get_pex_peer_lists(self, peer_key: str) -> tuple[bytes, bytes]:
peer_key: The peer we're sending to (will exclude from peer list)
Returns:
- Tuple of (added_peers_data, dropped_peers_data) as bytes
- Each is empty bytes if no peers of that type
+ Tuple of (added_payload, dropped_payload) as bytes.
+ Each is BEP11 payload bytes (bencoded dict) or empty bytes.
"""
try:
@@ -374,16 +390,13 @@ async def _get_pex_peer_lists(self, peer_key: str) -> tuple[bytes, bytes]:
# Update previous connected peers for next time
self.previous_connected_peers[peer_key] = current_connected.copy()
- # Encode peer lists
+ # Encode BEP11 payloads
pex_exchange = PeerExchange()
- added_data = b""
- if pex_peers_to_add:
- added_data = pex_exchange.encode_peers_list(pex_peers_to_add)
-
- dropped_data = b""
- if pex_peers_to_drop:
- dropped_data = pex_exchange.encode_peers_list(pex_peers_to_drop)
+ added_data = pex_exchange.encode_bep11_payload(added_peers=pex_peers_to_add)
+ dropped_data = pex_exchange.encode_bep11_payload(
+ dropped_peers=pex_peers_to_drop
+ )
if added_data or dropped_data:
self.logger.debug(
@@ -521,7 +534,11 @@ async def add_peers(self, peers: list[PexPeer]) -> None:
self.logger.debug("Error calling PEX callback: %s", e)
async def refresh(self) -> None:
- """Manually trigger PEX refresh to all supported peers."""
+ """Manually trigger PEX refresh to all supported peers.
+
+ Session code may call this when DHT ``get_peers`` is rate-limited so PEX
+ still drives peer exchange as a complement to throttled DHT lookups.
+ """
refreshed_count = 0
# Reset last_pex_time for all sessions to allow immediate refresh
diff --git a/ccbt/discovery/tracker.py b/ccbt/discovery/tracker.py
index 557f5c77..c593e4b4 100644
--- a/ccbt/discovery/tracker.py
+++ b/ccbt/discovery/tracker.py
@@ -9,22 +9,58 @@
import asyncio
import contextlib
+import inspect
+import ipaddress
import logging
import re
+import ssl
import time
import urllib.error
import urllib.parse
import urllib.request
+from contextvars import ContextVar
from dataclasses import dataclass
-from typing import Any, Callable, Optional, Union
+from typing import Any, Awaitable, Callable, Optional, Protocol, Union, cast
import aiohttp
from ccbt.config.config import get_config
from ccbt.core.bencode import BencodeDecoder
from ccbt.models import PeerInfo
+from ccbt.security.ssl_context import CertificatePinner
+from ccbt.utils.tracker_utils import (
+ tracker_url_implies_tls,
+ tracker_url_is_udp,
+ tracker_url_transport_tier,
+)
from ccbt.utils.version import get_user_agent
+# Per-task: why announce() returned None (skip, not TrackerError).
+# Subscript ContextVar so default=None is typed as str|None, not the None singleton type.
+_tracker_announce_skip_reason: ContextVar[Optional[str]] = ContextVar[Optional[str]](
+ "_tracker_announce_skip_reason", default=None
+)
+
+
+class _UDPTrackerAnnounceProtocol(Protocol):
+ """Protocol for UDP tracker client used in announce path (ty/socket_ready)."""
+
+ @property
+ def socket_ready(self) -> bool: ...
+
+ async def announce_to_tracker_full(
+ self,
+ url: str,
+ torrent_data: dict[str, Any],
+ *,
+ port: Optional[int] = None,
+ uploaded: int = 0,
+ downloaded: int = 0,
+ left: int = 0,
+ event: Any = None,
+ on_immediate_peers: Any = None,
+ ) -> Union[Any, None]: ...
+
class TrackerError(Exception):
"""Exception raised when tracker communication fails."""
@@ -95,6 +131,27 @@ def get_stats(self) -> dict[str, Any]:
}
+@dataclass
+class UDPTrackerAnnounceRequest:
+ """Normalized argument set for UDP tracker announces."""
+
+ port: Optional[int]
+ uploaded: int = 0
+ downloaded: int = 0
+ left: int = 0
+ event: Optional[Any] = None
+
+ def as_kwargs(self) -> dict[str, Any]:
+ """Convert to kwargs for UDP announce calls."""
+ return {
+ "port": self.port,
+ "uploaded": self.uploaded,
+ "downloaded": self.downloaded,
+ "left": self.left,
+ "event": self.event,
+ }
+
+
@dataclass
class TrackerResponse:
"""Tracker response data."""
@@ -145,8 +202,11 @@ class TrackerSession:
min_interval: Optional[int] = None
tracker_id: Optional[str] = None
failure_count: int = 0
+ failure_streak: int = 0
last_failure: float = 0.0
backoff_delay: float = 1.0
+ quarantine_until: float = 0.0
+ quarantine_reason: Optional[str] = None
performance: TrackerPerformance = None # type: ignore[assignment]
# Statistics from last tracker response (announce or scrape)
last_complete: Optional[int] = None # Number of seeders (complete peers)
@@ -163,14 +223,20 @@ def __post_init__(self):
class AsyncTrackerClient:
"""High-performance async client for communicating with BitTorrent trackers."""
- def __init__(self, peer_id_prefix: Optional[bytes] = None):
+ def __init__(
+ self,
+ peer_id_prefix: Optional[bytes] = None,
+ session_manager: Optional[Any] = None,
+ ):
"""Initialize the async tracker client.
Args:
peer_id_prefix: Prefix for generating peer IDs. If None, uses ccBitTorrent prefix -CC0101-.
+ session_manager: Optional AsyncSessionManager for process-wide UDP tracker client (BEP 15).
"""
self.config = get_config()
+ self._session_manager: Optional[Any] = session_manager
if peer_id_prefix is None:
# Use ccBitTorrent-specific prefix -CC0101- instead of version-based -BT0001-
# This matches the expected format for ccBitTorrent client identification
@@ -197,16 +263,21 @@ def __init__(self, peer_id_prefix: Optional[bytes] = None):
# Session metrics
self._session_metrics: dict[str, dict[str, Any]] = {}
- self._xet_chunk_registry: dict[tuple[bytes, Optional[str]], list[PeerInfo]] = {}
+ self._xet_chunk_registry: dict[tuple[bytes, str | None], list[PeerInfo]] = {}
+ self._tracker_certificate_pinner: Optional[CertificatePinner] = None
self.logger = logging.getLogger(__name__)
+ self._immediate_connection_window = 0.25
+ self._immediate_connection_lock: asyncio.Lock = asyncio.Lock()
+ self._pending_immediate_peers: dict[str, list[dict[str, Any]]] = {}
+ self._immediate_connection_tasks: dict[str, asyncio.Task[None]] = {}
- # CRITICAL FIX: Immediate peer connection callback
+ # Note: Immediate peer connection callback
# This allows sessions to connect peers immediately when tracker responses arrive
# instead of waiting for the announce loop to process them
- self.on_peers_received: Optional[
- Callable[[Union[list[PeerInfo], list[dict[str, Any]]], str], None]
- ] = None
+ self.on_peers_received: (
+ None | (Callable[[list[dict[str, Any]], str], Awaitable[None] | None])
+ ) = None
async def announce_chunk(
self,
@@ -249,23 +320,60 @@ async def _call_immediate_connection(
self, peers: list[dict[str, Any]], tracker_url: str
) -> None:
"""Call immediate connection callback asynchronously."""
- if self.on_peers_received:
+ if not peers:
+ return
+ if not self.on_peers_received:
+ return
+
+ self._pending_immediate_peers.setdefault(tracker_url, [])
+
+ async with self._immediate_connection_lock:
+ pending_batch = self._pending_immediate_peers[tracker_url]
+ seen_peer_keys = {
+ (peer.get("ip"), peer.get("port")) for peer in pending_batch
+ }
+ for peer in peers:
+ peer_key = (peer.get("ip"), peer.get("port"))
+ if peer_key not in seen_peer_keys:
+ pending_batch.append(peer)
+ seen_peer_keys.add(peer_key)
+
+ if tracker_url not in self._immediate_connection_tasks:
+ self._immediate_connection_tasks[tracker_url] = asyncio.create_task(
+ self._flush_immediate_connection(tracker_url)
+ )
+
+ async def _flush_immediate_connection(self, tracker_url: str) -> None:
+ """Flush merged immediate peers after debounce window and invoke callback."""
+ try:
+ await asyncio.sleep(self._immediate_connection_window)
+
+ async with self._immediate_connection_lock:
+ peers = self._pending_immediate_peers.pop(tracker_url, [])
+ self._immediate_connection_tasks.pop(tracker_url, None)
+
+ if not peers:
+ return
+
try:
- # Call the callback - it should be async-safe
- if asyncio.iscoroutinefunction(self.on_peers_received):
- await self.on_peers_received(peers, tracker_url)
- else:
- self.on_peers_received(peers, tracker_url)
+ callback = self.on_peers_received
+ if callback is None:
+ return
+ result = callback(peers, tracker_url)
+ if inspect.isawaitable(result):
+ await result
except Exception as e:
self.logger.warning(
"Error in immediate peer connection callback: %s",
e,
exc_info=True,
)
+ except asyncio.CancelledError:
+ return
async def start(self) -> None:
"""Start the async tracker client."""
- # CRITICAL FIX: Close existing session if it exists before creating a new one
+ # Note: Close existing session if it exists before creating a new one
# This prevents resource leaks when start() is called multiple times
if self.session and not self.session.closed:
try:
@@ -328,14 +436,25 @@ def _create_connector(
ssl_context = builder.create_tracker_context()
self.logger.debug("Created SSL context for tracker connections")
except Exception as e: # pragma: no cover - SSL context creation error, tested via successful creation
- self.logger.warning(
- "Failed to create SSL context for trackers: %s. "
- "HTTPS connections may fail or use system default SSL context.",
- e,
- exc_info=True,
- )
# Continue without SSL context (fallback to system default)
# Note: aiohttp will use system default SSL context if ssl=None
+ # Keep explicit log category for CA/certificate material failures.
+ ssl_context_msg = str(e).lower()
+ if (
+ "ca" in ssl_context_msg
+ or "certificate" in ssl_context_msg
+ or "cert" in ssl_context_msg
+ ):
+ self.logger.exception(
+ "Tracker CA/certificate configuration issue for HTTPS trackers"
+ )
+ else:
+ self.logger.warning(
+ "Failed to create SSL context for trackers: %s. "
+ "HTTPS connections may fail or use system default SSL context.",
+ e,
+ exc_info=True,
+ )
ssl_context = None
# Check if proxy is enabled and should be used for trackers
@@ -450,15 +569,15 @@ async def stop(self) -> None:
# Clear task reference
self._announce_task = None
- # CRITICAL FIX: Properly close HTTP session to prevent "Unclosed client session" warnings
+ # Note: Properly close HTTP session to prevent "Unclosed client session" warnings
if self.session:
try:
- # CRITICAL FIX: Ensure session is fully closed before setting to None
+ # Note: Ensure session is fully closed before setting to None
# Use context manager pattern to ensure cleanup even if close() raises
if not self.session.closed:
- # CRITICAL FIX: Close all connectors to ensure complete cleanup
+ # Note: Close all connectors to ensure complete cleanup
await self.session.close()
- # CRITICAL FIX: Wait longer for session to fully close (especially on Windows)
+ # Note: Wait longer for session to fully close (especially on Windows)
# This prevents "Unclosed client session" warnings
# On Windows, aiohttp sessions may need more time to fully close
import sys
@@ -468,7 +587,7 @@ async def stop(self) -> None:
else:
await asyncio.sleep(0.1)
- # CRITICAL FIX: Close connector explicitly to ensure complete cleanup
+ # Note: Close connector explicitly to ensure complete cleanup
# This is especially important on Windows where connector cleanup can be delayed
if hasattr(self.session, "connector") and self.session.connector:
connector = self.session.connector
@@ -482,14 +601,14 @@ async def stop(self) -> None:
except Exception as e:
self.logger.debug("Error closing connector: %s", e)
- # CRITICAL FIX: Verify session is actually closed
+ # Note: Verify session is actually closed
if not self.session.closed:
self.logger.warning(
"HTTP session not fully closed after close() call"
)
except Exception as e:
self.logger.debug("Error closing HTTP session: %s", e)
- # CRITICAL FIX: Even if close() fails, try to clean up connector
+ # Note: Even if close() fails, try to clean up connector
try:
if hasattr(self.session, "connector") and self.session.connector:
connector = self.session.connector
@@ -505,7 +624,7 @@ async def stop(self) -> None:
except Exception:
pass
finally:
- # CRITICAL FIX: Always set to None even if close() fails
+ # Note: Always set to None even if close() fails
self.session = None
# Stop tracker health manager
@@ -591,11 +710,157 @@ def get_session_metrics(self) -> dict[str, dict[str, Any]]:
metrics.get("connection_reuse_count", 0) / request_count * 100
),
"error_rate": (metrics.get("error_count", 0) / request_count * 100),
+ "resolution_anomaly_count": metrics.get(
+ "resolution_anomaly_count", 0
+ ),
+ "udp_timeout_count": metrics.get("udp_timeout_count", 0),
+ "udp_connect_failure_count": metrics.get(
+ "udp_connect_failure_count", 0
+ ),
+ "http_fallback_attempt_count": metrics.get(
+ "http_fallback_attempt_count", 0
+ ),
+ "http_fallback_failure_count": metrics.get(
+ "http_fallback_failure_count", 0
+ ),
}
else: # pragma: no cover - Zero request count path, tested via stats with requests
stats[host] = metrics
return stats
+ def _ensure_session_metric_bucket(self, tracker_host: str) -> dict[str, Any]:
+ """Return the metric bucket for a tracker host, creating it if needed."""
+ if tracker_host not in self._session_metrics:
+ self._session_metrics[tracker_host] = {
+ "request_count": 0,
+ "total_request_time": 0.0,
+ "total_dns_time": 0.0,
+ "connection_reuse_count": 0,
+ "error_count": 0,
+ "resolution_anomaly_count": 0,
+ "udp_timeout_count": 0,
+ "udp_connect_failure_count": 0,
+ "http_fallback_attempt_count": 0,
+ "http_fallback_failure_count": 0,
+ }
+ return self._session_metrics[tracker_host]
+
+ def _increment_session_metric(
+ self, tracker_host: str, metric_name: str, amount: int = 1
+ ) -> None:
+ """Increment a tracker session metric."""
+ metrics = self._ensure_session_metric_bucket(tracker_host)
+ metrics[metric_name] = int(metrics.get(metric_name, 0) or 0) + amount
+
+ def _record_tracker_resolution_anomaly(
+ self,
+ tracker_host: str,
+ scheme: str,
+ error: Exception,
+ ) -> None:
+ """Record when a public tracker resolves to a loopback/private address."""
+ error_text = str(error)
+ resolved_matches = re.findall(
+ r"\('([^']+)',\s*\d+\)",
+ error_text,
+ )
+ if not resolved_matches:
+ return
+ resolved_host = resolved_matches[-1]
+ try:
+ parsed_ip = ipaddress.ip_address(resolved_host)
+ except ValueError:
+ return
+ if not (parsed_ip.is_loopback or parsed_ip.is_private):
+ return
+ self._increment_session_metric(tracker_host, "resolution_anomaly_count")
+ self.logger.warning(
+ "TRACKER_RESOLUTION_ANOMALY: %s tracker %s resolved to %s during connect/fallback. This usually indicates hosts-file overrides, DNS filtering, proxy interception, or endpoint security software.",
+ scheme.upper(),
+ tracker_host,
+ resolved_host,
+ )
+
+ def _get_tracker_certificate_pinner(self) -> Union[CertificatePinner, None]:
+ """Return configured certificate pinner for HTTPS trackers."""
+ if self._tracker_certificate_pinner is None:
+ ssl_config = self.config.security.ssl if self.config.security else None
+ if not ssl_config:
+ return None
+
+ tracker_pins = getattr(ssl_config, "ssl_tracker_pins", {})
+ if not tracker_pins:
+ return None
+
+ pinner = CertificatePinner()
+ for hostname, fingerprint in tracker_pins.items():
+ if not hostname or not fingerprint:
+ continue
+ pinner.pin_certificate(str(hostname).lower(), str(fingerprint))
+
+ self._tracker_certificate_pinner = pinner
+
+ return self._tracker_certificate_pinner
+
+ def _extract_tracker_ssl_object(self, response: aiohttp.ClientResponse) -> Any:
+ """Extract SSL object from an aiohttp response."""
+ connection = getattr(response, "connection", None)
+ if connection is None:
+ return None
+
+ transport = getattr(connection, "_transport", None)
+ if transport is None:
+ transport = getattr(connection, "transport", None)
+ if transport is None:
+ protocol = getattr(connection, "_protocol", None)
+ if protocol is not None:
+ transport = getattr(protocol, "_transport", None)
+ if transport is None:
+ return None
+
+ get_extra_info = getattr(transport, "get_extra_info", None)
+ if not callable(get_extra_info):
+ return None
+ return get_extra_info("ssl_object")
+
+ def _verify_tracker_certificate_pin(
+ self, tracker_host: str, response: aiohttp.ClientResponse
+ ) -> None:
+ """Verify the TLS certificate against configured tracker pinning."""
+ pinner = self._get_tracker_certificate_pinner()
+ if pinner is None:
+ return
+
+ ssl_object = self._extract_tracker_ssl_object(response)
+ if ssl_object is None:
+ msg = (
+ f"Tracker certificate pinning is configured but HTTPS peer certificate "
+ f"was not available for {tracker_host}"
+ )
+ raise ssl.SSLError(msg)
+
+ cert: Union[bytes, dict[str, Any]]
+ cert = ssl_object.getpeercert()
+ if not cert:
+ cert = ssl_object.getpeercert(binary_form=True)
+
+ if not pinner.verify_pin(tracker_host.lower(), cert):
+ msg = f"Tracker certificate pin mismatch for {tracker_host}"
+ raise ssl.SSLError(msg)
+
+ def _classify_tracker_ssl_error(self, error: Exception) -> str:
+ """Map HTTPS SSL errors to stable error categories."""
+ message = str(error).lower()
+ if "certificate verify failed" in message:
+ if "hostname" in message:
+ return "TLS certificate hostname mismatch"
+ return "TLS certificate verification failed"
+ if "certificate" in message and "pin" in message:
+ return "TLS certificate pin mismatch"
+ if "handshake" in message:
+ return "TLS handshake failure"
+ return "TLS connection failure"
+
def rank_trackers(self, tracker_urls: list[str]) -> list[str]:
"""Rank trackers by performance metrics.
@@ -667,6 +932,194 @@ def rank_trackers(self, tracker_urls: list[str]) -> list[str]:
# Return ranked URLs
return [url for _, url in tracker_scores]
+ @staticmethod
+ def _is_invalid_payload_failure(failure_reason: Union[str, None]) -> bool:
+ if not failure_reason:
+ return False
+ normalized_reason = failure_reason.lower()
+ return any(
+ marker in normalized_reason
+ for marker in (
+ "invalid tracker payload",
+ "non-bencode",
+ "invalid tracker",
+ "not bencode",
+ "html/xml payload",
+ "json-like payload",
+ "plain/integer payload",
+ )
+ )
+
+ @staticmethod
+ def _classify_tracker_failure_tier(failure_reason: Union[str, None]) -> str:
+ """Classify tracker failure into quarantine severity tiers."""
+ if not failure_reason:
+ return "ignore"
+ normalized_reason = failure_reason.lower()
+ if "html/xml payload" in normalized_reason:
+ return "critical"
+ if (
+ "json-like payload" in normalized_reason
+ or "plain/integer payload" in normalized_reason
+ ):
+ return "high"
+ if (
+ "timeout" in normalized_reason
+ or "timed out" in normalized_reason
+ or "connection refused" in normalized_reason
+ or "connection reset" in normalized_reason
+ or "name resolution" in normalized_reason
+ or "unreachable" in normalized_reason
+ or "temporarily unavailable" in normalized_reason
+ ):
+ return "medium"
+ return "low"
+
+ @staticmethod
+ def _is_retryable_tracker_failure(failure_reason: Union[str, None]) -> bool:
+ if not failure_reason:
+ return False
+ normalized_reason = failure_reason.lower()
+ if AsyncTrackerClient._is_invalid_payload_failure(normalized_reason):
+ return False
+ non_retryable_markers = (
+ "tracker failure:",
+ "failure reason",
+ "invalid tracker payload",
+ "invalid bencode",
+ "invalid tracker",
+ )
+ if any(marker in normalized_reason for marker in non_retryable_markers):
+ return False
+ retryable_markers = (
+ "timeout",
+ "timed out",
+ "connection",
+ "connect",
+ "connection refused",
+ "name resolution",
+ "unreachable",
+ "no response",
+ "temporary",
+ "reset",
+ "closed",
+ "failed to parse tracker response",
+ "parse tracker response",
+ )
+ return any(marker in normalized_reason for marker in retryable_markers)
+
+ def _all_failures_are_retryable(
+ self,
+ failed_trackers: list[tuple[str, Exception]],
+ ) -> bool:
+ if not failed_trackers:
+ return False
+ return all(
+ self._is_retryable_tracker_failure(str(failure))
+ for _, failure in failed_trackers
+ )
+
+ def _apply_tracker_quarantine(
+ self,
+ session: TrackerSession,
+ failure_reason: Optional[str] = None,
+ failure_count: Optional[int] = None,
+ ) -> None:
+ failure_reason = failure_reason or "unclassified tracker failure"
+ failure_streak = (
+ failure_count if failure_count is not None else session.failure_streak
+ )
+ failure_tier = self._classify_tracker_failure_tier(failure_reason)
+ tracker_host = urllib.parse.urlparse(session.url).hostname or ""
+ if tracker_host:
+ self._increment_session_metric(tracker_host, f"failure_tier_{failure_tier}")
+
+ if failure_tier == "critical":
+ # Malformed HTML responses are almost certainly wrong endpoint usage.
+ cooldown_seconds = float(
+ self.config.network.tracker_payload_failure_quarantine_seconds
+ )
+ should_quarantine = failure_streak >= 1
+ elif failure_tier == "high":
+ # Parse/encoding glitches on otherwise reachable trackers are transient.
+ cooldown_seconds = float(
+ self.config.network.tracker_payload_failure_quarantine_seconds
+ )
+ cooldown_seconds = max(min(cooldown_seconds, 45.0), 10.0)
+ should_quarantine = failure_streak >= 3
+ elif failure_tier == "medium":
+ # Repeated network failures should throttle requests temporarily.
+ cooldown_seconds = float(
+ self.config.network.tracker_network_failure_quarantine_seconds
+ )
+ cooldown_seconds = max(min(cooldown_seconds, 240.0), 15.0)
+ should_quarantine = failure_streak >= 2
+ norm = (failure_reason or "").lower()
+ dns_or_refused = any(
+ marker in norm
+ for marker in (
+ "name resolution",
+ "getaddrinfo",
+ "temporary failure in name resolution",
+ "connection refused",
+ )
+ )
+ esc = int(self.config.network.tracker_dns_refused_escalation_streak)
+ if dns_or_refused and failure_streak >= esc:
+ should_quarantine = failure_streak >= 1
+ cooldown_seconds = min(cooldown_seconds * 1.35, 600.0)
+ if failure_streak >= esc + 2:
+ cooldown_seconds = min(cooldown_seconds * 1.5, 900.0)
+ else:
+ cooldown_seconds = 0.0
+ should_quarantine = False
+
+ if not should_quarantine:
+ return
+
+ if cooldown_seconds < 0.0:
+ cooldown_seconds = 120.0
+ min_eligible_raw = getattr(self, "_tracker_quarantine_min_eligible_urls", None)
+ if min_eligible_raw is None:
+ min_eligible_raw = getattr(
+ self.config.network,
+ "tracker_quarantine_min_eligible_urls",
+ 0,
+ )
+ min_eligible_urls = max(0, int(min_eligible_raw or 0))
+ if min_eligible_urls > 0:
+ current_time = time.time()
+ eligible_other_urls = {
+ tracker_url
+ for tracker_url, other_session in self.sessions.items()
+ if tracker_url != session.url
+ and (
+ not other_session.quarantine_until
+ or current_time >= other_session.quarantine_until
+ )
+ }
+ if len(eligible_other_urls) < min_eligible_urls:
+ self.logger.info(
+ "Skipping tracker quarantine for %s to preserve tracker diversity "
+ "(eligible_other_urls=%d, min_required=%d, tier=%s, failure_streak=%d)",
+ session.url,
+ len(eligible_other_urls),
+ min_eligible_urls,
+ failure_tier,
+ failure_streak,
+ )
+ return
+ session.quarantine_until = time.time() + cooldown_seconds
+ session.quarantine_reason = failure_reason[:240]
+ self.logger.warning(
+ "Tracker %s quarantined for %.1fs after %d %s-tier failures: %s",
+ session.url,
+ cooldown_seconds,
+ failure_tier,
+ failure_streak,
+ failure_reason,
+ )
+
def _calculate_adaptive_interval(
self,
tracker_url: str,
@@ -828,7 +1281,7 @@ async def announce(
downloaded: int = 0,
left: Optional[int] = None,
event: str = "started",
- ) -> Optional[TrackerResponse]:
+ ) -> Union[TrackerResponse, None]:
"""Announce to the tracker and get peer list asynchronously.
Args:
@@ -851,7 +1304,8 @@ async def announce(
raise TrackerError(msg)
try:
- # CRITICAL FIX: Validate torrent_data is a dict before accessing it
+ _tracker_announce_skip_reason.set(None)
+ # Note: Validate torrent_data is a dict before accessing it
# Log immediately for debugging
self.logger.debug(
"tracker.announce() called with torrent_data type=%s, is_list=%s, is_dict=%s",
@@ -884,12 +1338,14 @@ async def announce(
raise TrackerError(error_msg)
# Generate peer ID if not already present (only if dict)
- if isinstance(torrent_data, dict) and "peer_id" not in torrent_data:
+ if isinstance(torrent_data, dict) and (
+ "peer_id" not in torrent_data or not torrent_data.get("peer_id")
+ ):
torrent_data["peer_id"] = self._generate_peer_id()
# Set left to total file size if not specified
if left is None:
- # CRITICAL FIX: Handle missing or None file_info - validate torrent_data is dict first
+ # Note: Handle missing or None file_info - validate torrent_data is dict first
if isinstance(torrent_data, dict):
file_info = torrent_data.get("file_info")
if file_info and isinstance(file_info, dict):
@@ -903,31 +1359,36 @@ async def announce(
else:
left = 0 # Default to 0 if file_info not available
- # CRITICAL FIX: Use large but reasonable value for magnet links without metadata
+ # Note: Use large but reasonable value for magnet links without metadata
# left=0 means "completed download" to trackers, so they won't return peers
# Using max int64 (2^63-1) may confuse some trackers, so use a large reasonable value instead
# 1TB (1099511627776 bytes) is large enough to indicate "unknown size, downloading full file"
# but not so large that it causes issues with tracker implementations
if isinstance(torrent_data, dict):
- file_info = torrent_data.get("file_info", {})
+ file_info = torrent_data.get("file_info")
+ total_length = 0
if isinstance(file_info, dict):
- total_length = file_info.get("total_length", 0)
- # If total_length is 0, this is a magnet link without metadata
- # Use a large but reasonable value to indicate "unknown size, need full file" (not "completed")
- if total_length == 0:
- # Use 1TB (1099511627776 bytes) - large enough to indicate "unknown size"
- # but reasonable enough that trackers won't reject it
- # This is better than max int64 which some trackers may not handle correctly
- large_left = 1099511627776 # 1 TB
- if left != large_left:
- self.logger.debug(
- "Magnet link without metadata detected (total_length=0), using left=%d (1TB) to indicate 'unknown size, need full file' (was %d)",
- large_left,
- left,
- )
- left = large_left
+ total_length = int(file_info.get("total_length", 0) or 0)
+ metadata_incomplete = bool(
+ torrent_data.get("_metadata_incomplete", False)
+ )
+ is_magnet = bool(torrent_data.get("is_magnet", False))
+ if (
+ metadata_incomplete or is_magnet or file_info is None
+ ) and total_length == 0:
+ # Use 1TB (1099511627776 bytes) - large enough to indicate "unknown size"
+ # but reasonable enough that trackers won't reject it
+ # This is better than max int64 which some trackers may not handle correctly
+ large_left = 1099511627776 # 1 TB
+ if left != large_left:
+ self.logger.debug(
+ "Metadata-incomplete torrent detected, using left=%d (1TB) to indicate 'unknown size, need full file' (was %d)",
+ large_left,
+ left,
+ )
+ left = large_left
- # CRITICAL FIX: Validate required fields before building URL
+ # Note: Validate required fields before building URL
# Handle both dict and object access patterns
announce_url = (
torrent_data.get("announce")
@@ -955,7 +1416,7 @@ async def announce(
msg = "No peer_id in torrent data"
raise TrackerError(msg)
- # CRITICAL FIX: Ensure info_hash and peer_id are bytes, not strings
+ # Note: Ensure info_hash and peer_id are bytes, not strings
# Convert hex strings to bytes if needed
if isinstance(info_hash_raw, str):
# Try to decode as hex string (40 chars = 20 bytes, 64 chars = 32 bytes for v2/XET)
@@ -1061,7 +1522,7 @@ async def announce(
event,
)
- # CRITICAL FIX: Detect UDP trackers and route to UDP client
+ # Note: Detect UDP trackers and route to UDP client
# Normalize URL first to ensure proper format detection
normalized_url = self._normalize_tracker_url(announce_url)
@@ -1072,7 +1533,7 @@ async def announce(
peer_id_hex = (
peer_id.hex()[:20] if isinstance(peer_id, bytes) else str(peer_id)[:20]
)
- self.logger.info(
+ self.logger.debug(
"TRACKER_REQUEST: url=%s, info_hash=%s, peer_id=%s, port=%d, uploaded=%d, downloaded=%d, left=%d, event=%s",
normalized_url[:100] if len(normalized_url) > 100 else normalized_url,
info_hash_hex,
@@ -1084,7 +1545,9 @@ async def announce(
event,
)
- is_udp = normalized_url.startswith("udp://")
+ is_udp = tracker_url_is_udp(normalized_url)
+ fallback_url: Optional[str] = None
+ tracker_host = urllib.parse.urlparse(normalized_url).hostname or ""
# BEP 15 (UDP) uses 20-byte info_hash; BEP 41 extends UDP with URLData only. Skip UDP for 32-byte (XET).
if is_udp and len(info_hash) == 32:
@@ -1100,8 +1563,7 @@ async def announce(
)
if is_udp:
- # Route to UDP tracker client
- # CRITICAL FIX: Singleton pattern removed - use session_manager.udp_tracker_client
+ # Route to UDP tracker client (process-wide socket via session_manager.udp_tracker_client).
# Socket must be initialized during daemon startup and never recreated
# This prevents WinError 10022 on Windows and ensures proper socket lifecycle
udp_client = None
@@ -1117,7 +1579,7 @@ async def announce(
"Using session manager's initialized UDP tracker client"
)
- # CRITICAL FIX: Handle missing UDP tracker client gracefully
+ # Note: Handle missing UDP tracker client gracefully
# If UDP tracker client is not available (e.g., port binding failed),
# log warning and skip UDP tracker announce, but continue with HTTP trackers
if udp_client is None:
@@ -1130,21 +1592,22 @@ async def announce(
)
# Don't raise - skip this UDP tracker and continue with HTTP trackers
# This allows downloads to work even if UDP tracker client initialization failed
+ _tracker_announce_skip_reason.set("udp_client_unavailable")
return None
- # CRITICAL FIX: Validate socket is ready before use
+ # Note: Validate socket is ready before use
# Socket should NEVER be recreated - if invalid, fail gracefully
# Type narrowing: udp_client is guaranteed to be non-None after check above
- from ccbt.discovery.tracker_udp_client import AsyncUDPTrackerClient
-
- if not isinstance(udp_client, AsyncUDPTrackerClient):
- self.logger.warning("UDP tracker client type mismatch")
+ if not hasattr(udp_client, "announce_to_tracker_full"):
+ self.logger.warning("UDP tracker client missing announce API")
+ _tracker_announce_skip_reason.set("udp_client_missing_announce_api")
return None
+ udp_client_typed = cast("_UDPTrackerAnnounceProtocol", udp_client)
if (
udp_client.transport is None # type: ignore[attr-defined]
or udp_client.transport.is_closing() # type: ignore[attr-defined]
- or not udp_client.socket_ready
+ or not udp_client_typed.socket_ready
):
# CRITICAL: Socket should have been initialized during daemon startup
# If it's invalid here, this indicates a serious initialization issue
@@ -1156,7 +1619,7 @@ async def announce(
udp_client.transport.is_closing() # type: ignore[attr-defined]
if udp_client.transport # type: ignore[attr-defined]
else None,
- udp_client.socket_ready,
+ udp_client_typed.socket_ready,
)
msg = (
"UDP tracker client socket is invalid. "
@@ -1190,31 +1653,68 @@ async def announce(
else:
single_tracker_data = torrent_data
- # Use the full response method to get interval, seeders, leechers
- # CRITICAL FIX: Pass port parameter to UDP tracker client to use external port
- udp_result = await udp_client.announce_to_tracker_full(
- tracker_url,
- single_tracker_data,
+ # Use a UDP-only announce payload map with no HTTP transport kwargs.
+ udp_request = UDPTrackerAnnounceRequest(
port=port, # Use external port from NAT manager if available
uploaded=uploaded,
downloaded=downloaded,
left=left_value,
event=udp_event,
)
+ # Guardrail: UDP announces must not receive HTTP-only TLS/crypto kwargs.
+ # Keep payload explicit to avoid accidental bleed-through from HTTP tracker path.
+ disallowed_udp_kwargs = {
+ "ssl",
+ "supportcrypto",
+ "requirecrypto",
+ "cryptoport",
+ }
+ invalid_kwargs = (
+ set(udp_request.as_kwargs()) & disallowed_udp_kwargs
+ )
+ if invalid_kwargs:
+ msg = f"UDP announce path received HTTP-only kwargs: {sorted(invalid_kwargs)}"
+ raise TrackerError(msg)
+
+ # Use the full response method to get interval, seeders, leechers.
+ # Per-announce immediate connect avoids last-writer-wins on shared UDP client.
+ udp_result = await udp_client_typed.announce_to_tracker_full(
+ tracker_url,
+ single_tracker_data,
+ **udp_request.as_kwargs(),
+ on_immediate_peers=self.on_peers_received,
+ )
if udp_result is None:
- # CRITICAL FIX: When UDP tracker fails, try HTTP fallback
- # Convert udp:// to http:// and try HTTP tracker
- http_url = normalized_url.replace("udp://", "http://", 1)
- self.logger.info(
- "UDP tracker announce failed for %s, trying HTTP fallback: %s",
- normalized_url,
- http_url,
+ self._increment_session_metric(
+ tracker_host, "udp_timeout_count"
)
- # Fall through to HTTP tracker logic below
- # Update normalized_url to HTTP version for HTTP tracker processing
- normalized_url = http_url
- is_udp = False
+ fallback_url = self._find_http_fallback_url(
+ torrent_data, normalized_url
+ )
+ if fallback_url:
+ self._increment_session_metric(
+ tracker_host, "http_fallback_attempt_count"
+ )
+ self.logger.debug(
+ "UDP tracker announce failed for %s, trying explicit HTTP fallback: %s",
+ normalized_url,
+ fallback_url,
+ )
+ normalized_url = fallback_url
+ is_udp = False
+ else:
+ self._increment_session_metric(
+ tracker_host, "http_fallback_invalid_count"
+ )
+ self.logger.warning(
+ "UDP tracker announce failed for %s and no explicit HTTP fallback tracker is configured; treating tracker as UDP-only",
+ normalized_url,
+ )
+ _tracker_announce_skip_reason.set(
+ "udp_announce_no_response_no_http_fallback"
+ )
+ return None
else:
# UDP announce succeeded - return result
peers, interval, seeders, leechers = udp_result
@@ -1227,22 +1727,42 @@ async def announce(
incomplete=leechers, # Use 'incomplete' instead of 'leechers'
)
except Exception as udp_error:
- # CRITICAL FIX: When UDP tracker fails with exception, try HTTP fallback
- self.logger.debug(
- "UDP tracker announce failed for %s: %s, trying HTTP fallback",
- normalized_url,
- udp_error,
+ self._increment_session_metric(
+ tracker_host, "udp_connect_failure_count"
+ )
+ fallback_url = self._find_http_fallback_url(
+ torrent_data, normalized_url
)
- # Convert udp:// to http:// and try HTTP tracker
- http_url = normalized_url.replace("udp://", "http://", 1)
- normalized_url = http_url
- is_udp = False
- # Continue with HTTP tracker logic below
+ if fallback_url:
+ self._increment_session_metric(
+ tracker_host, "http_fallback_attempt_count"
+ )
+ self.logger.debug(
+ "UDP tracker announce failed for %s: %s, trying explicit HTTP fallback %s",
+ normalized_url,
+ udp_error,
+ fallback_url,
+ )
+ normalized_url = fallback_url
+ is_udp = False
+ else:
+ self._increment_session_metric(
+ tracker_host, "http_fallback_invalid_count"
+ )
+ self.logger.warning(
+ "UDP tracker announce failed for %s: %s, and no explicit HTTP fallback tracker exists (UDP-only path)",
+ normalized_url,
+ udp_error,
+ )
+ _tracker_announce_skip_reason.set(
+ "udp_announce_error_no_http_fallback"
+ )
+ return None
if not is_udp:
# HTTP tracker announce (including fallback from UDP)
- # CRITICAL FIX: Handle HTTP tracker announce (including fallback from UDP)
- if normalized_url.startswith(("http://", "https://")):
+ # Note: Handle HTTP tracker announce (including fallback from UDP)
+ if tracker_url_transport_tier(normalized_url) in {"HTTP", "HTTPS"}:
self.logger.debug(
"Using HTTP tracker for %s",
normalized_url,
@@ -1258,13 +1778,14 @@ async def announce(
downloaded,
left_value,
event,
+ crypto_flags=self._parse_tracker_crypto_flags(normalized_url),
)
# Make async HTTP request
response_data = await self._make_request_async(tracker_url)
# Parse response
- response = self._parse_response_async(response_data)
+ response = self._parse_response_async(response_data, normalized_url)
# Track performance
response_time = time.time() - start_time
@@ -1281,6 +1802,7 @@ async def announce(
"Unsupported tracker protocol for %s (expected udp://, http://, or https://)",
normalized_url,
)
+ _tracker_announce_skip_reason.set("unsupported_tracker_protocol")
return None
# If we reach here and is_udp is still True, UDP failed but no fallback was attempted
@@ -1300,7 +1822,7 @@ async def announce(
if is_udp and udp_result is not None:
udp_peers, udp_interval, udp_seeders, udp_leechers = udp_result
# Log if we got a response but no peers - this is unusual
- # CRITICAL FIX: Enhanced warning for 0 peers from trackers
+ # Note: Enhanced warning for 0 peers from trackers
# This is especially important for popular torrents where 0 peers is unusual
if (
not udp_peers
@@ -1321,11 +1843,11 @@ async def announce(
)
# Convert UDP response to TrackerResponse format
- # CRITICAL FIX: Convert dict peers to PeerInfo objects for type consistency
- # CRITICAL FIX: Log UDP peer count before conversion
+ # Note: Convert dict peers to PeerInfo objects for type consistency
+ # Note: Log UDP peer count before conversion
raw_peer_count = len(udp_peers) if udp_peers else 0
if raw_peer_count > 0:
- self.logger.info(
+ self.logger.debug(
"UDP tracker %s returned %d raw peer(s) before conversion (seeders=%s, leechers=%s)",
normalized_url,
raw_peer_count,
@@ -1376,7 +1898,7 @@ async def announce(
peer_dict,
)
- # CRITICAL FIX: Log conversion results at INFO/WARNING level for visibility
+ # Note: Log conversion results at INFO/WARNING level for visibility
if conversion_errors > 0:
self.logger.warning(
"Converted %d/%d peers from UDP tracker %s (skipped %d invalid)",
@@ -1392,7 +1914,7 @@ async def announce(
raw_peer_count,
)
elif len(peer_info_list) > 0:
- self.logger.info(
+ self.logger.debug(
"Successfully converted %d peer(s) from UDP tracker %s",
len(peer_info_list),
normalized_url,
@@ -1410,7 +1932,7 @@ async def announce(
)
# Enhanced logging with peer conversion results
- self.logger.info(
+ self.logger.debug(
"UDP tracker announce successful: %d peers (converted to %d PeerInfo objects), %d seeders, %d leechers, interval=%ds from %s",
len(udp_peers),
len(peer_info_list),
@@ -1467,7 +1989,7 @@ async def announce(
response_data = await self._make_request_async(tracker_url)
# Parse response
- response = self._parse_response_async(response_data)
+ response = self._parse_response_async(response_data, normalized_url)
# Track performance
response_time = time.time() - start_time
@@ -1520,7 +2042,7 @@ async def announce(
)
if announce_url:
- self._handle_tracker_failure(announce_url)
+ self._handle_tracker_failure(announce_url, failure_reason=str(e))
# Emit tracker announce error event
try:
@@ -1570,6 +2092,7 @@ async def announce_to_multiple(
downloaded: int = 0,
left: Optional[int] = None,
event: str = "started",
+ allow_all_failure_retry: bool = True,
) -> list[TrackerResponse]:
"""Announce to multiple trackers concurrently.
@@ -1581,6 +2104,7 @@ async def announce_to_multiple(
downloaded: Number of bytes downloaded
left: Number of bytes left to download
event: Event type
+ allow_all_failure_retry: Whether to allow all tracker announces to fail before raising.
Returns:
List of successful tracker responses
@@ -1594,10 +2118,83 @@ async def announce_to_multiple(
self.logger.warning("No tracker URLs provided for announce_to_multiple")
return []
+ ranked_urls = self.rank_trackers(tracker_urls)
+ normalized_urls: list[str] = []
+ for candidate_url in ranked_urls:
+ try:
+ normalized_candidate = self._normalize_tracker_url(candidate_url)
+ except TrackerError as exc:
+ tracker_host = (
+ urllib.parse.urlparse(candidate_url).hostname or "unknown"
+ )
+ self._increment_session_metric(tracker_host, "invalid_payload_count")
+ self.logger.warning(
+ "Skipping invalid tracker URL %s in multi-announce scheduling: %s",
+ candidate_url,
+ exc,
+ )
+ continue
+ if normalized_candidate not in normalized_urls:
+ normalized_urls.append(normalized_candidate)
+
+ if not normalized_urls:
+ self.logger.warning("No valid tracker URLs available after normalization")
+ return []
+
+ ranked_urls = normalized_urls
+ scheduled_urls: list[str] = []
+ deferred_urls: list[tuple[float, str]] = []
+ quarantined_urls: set[str] = set()
+ fallback_urls: list[str] = []
+ current_time = time.time()
+ for url in ranked_urls:
+ session = self.sessions.get(url)
+ if session is None:
+ self.sessions[url] = TrackerSession(url=url)
+ session = self.sessions[url]
+
+ if session.quarantine_until and current_time < session.quarantine_until:
+ quarantined_urls.add(url)
+ continue
+
+ backoff_until = session.last_failure + session.backoff_delay
+ if session.failure_count > 0 and current_time < backoff_until:
+ deferred_urls.append((backoff_until - current_time, url))
+ continue
+ scheduled_urls.append(url)
+
+ if not scheduled_urls:
+ # If all announced trackers were skipped, try a deterministic healthy fallback set.
+ fallback_urls = self.get_fallback_trackers(exclude_urls=set(ranked_urls))
+ if fallback_urls:
+ for fallback_url in fallback_urls:
+ if fallback_url not in scheduled_urls:
+ scheduled_urls.append(fallback_url)
+ if len(scheduled_urls) >= 3:
+ break
+
+ if not scheduled_urls and deferred_urls:
+ deferred_urls.sort(key=lambda item: item[0])
+ if len(deferred_urls) == len(ranked_urls):
+ self.logger.debug(
+ "All %d tracked endpoints are in backoff; postponing announce cycle",
+ len(deferred_urls),
+ )
+ return []
+ scheduled_urls.append(deferred_urls[0][1])
+ if len(scheduled_urls) != len(tracker_urls):
+ self.logger.debug(
+ "Announce scheduler deferred %d tracker(s) still in backoff and %d quarantined; scheduling %d tracker(s) this cycle",
+ len(tracker_urls) - len(scheduled_urls),
+ len(quarantined_urls),
+ len(scheduled_urls),
+ )
+ tracker_urls = scheduled_urls
+
# Log tracker types for debugging
udp_count = sum(1 for url in tracker_urls if url.startswith("udp://"))
http_count = len(tracker_urls) - udp_count
- self.logger.info(
+ self.logger.debug(
"Announcing to %d tracker(s) concurrently (%d UDP, %d HTTP/HTTPS)",
len(tracker_urls),
udp_count,
@@ -1607,9 +2204,15 @@ async def announce_to_multiple(
# Create announce tasks for all trackers
tasks = []
url_to_task = {} # Map URL to task for better error reporting
+ shared_torrent_data = torrent_data.copy()
+ if "peer_id" not in shared_torrent_data or not shared_torrent_data.get(
+ "peer_id"
+ ):
+ shared_torrent_data["peer_id"] = self._generate_peer_id()
+ failure_tracker_marks: dict[int, bool] = {}
for url in tracker_urls:
# Create a copy of torrent data with this tracker URL
- torrent_copy = torrent_data.copy()
+ torrent_copy = shared_torrent_data.copy()
torrent_copy["announce"] = url
task = asyncio.create_task(
@@ -1620,18 +2223,19 @@ async def announce_to_multiple(
downloaded,
left,
event,
+ _tracker_failure_marks=failure_tracker_marks,
),
)
tasks.append(task)
url_to_task[task] = url
# Wait for all announces to complete
- self.logger.info(
+ self.logger.debug(
"🔍 ANNOUNCE_TO_MULTIPLE: Waiting for %d tracker announce task(s) to complete...",
len(tasks),
)
results = await asyncio.gather(*tasks, return_exceptions=True)
- # CRITICAL FIX: Ensure all task exceptions are retrieved to prevent "Task exception was never retrieved" warnings
+ # Note: Ensure all task exceptions are retrieved to prevent "Task exception was never retrieved" warnings
# Even with return_exceptions=True, Python requires explicit exception retrieval to avoid warnings
for task, result in zip(tasks, results):
if isinstance(result, Exception):
@@ -1642,7 +2246,7 @@ async def announce_to_multiple(
_ = task.exception()
except Exception:
pass # Exception already in results list
- self.logger.info(
+ self.logger.debug(
"🔍 ANNOUNCE_TO_MULTIPLE: All %d tracker announce task(s) completed, processing results...",
len(results),
)
@@ -1651,13 +2255,17 @@ async def announce_to_multiple(
successful_responses = []
failed_trackers = []
total_peers = 0
+ timeout_count = 0
+ connection_error_count = 0
+ invalid_payload_count = 0
+ skipped_count = 0
for task, result in zip(tasks, results):
url = url_to_task.get(task, "unknown")
tracker_type = "UDP" if url.startswith("udp://") else "HTTP/HTTPS"
- # CRITICAL FIX: Enhanced logging to diagnose why responses aren't being processed
- self.logger.info(
+ # Note: Enhanced logging to diagnose why responses aren't being processed
+ self.logger.debug(
"🔍 ANNOUNCE_TO_MULTIPLE: Processing result for %s tracker %s (result_type=%s, is_TrackerResponse=%s)",
tracker_type,
url[:60] + "..." if len(url) > 60 else url,
@@ -1669,7 +2277,7 @@ async def announce_to_multiple(
successful_responses.append(result)
peer_count = len(result.peers) if result.peers else 0
total_peers += peer_count
- self.logger.info(
+ self.logger.debug(
"✅ %s tracker %s: %d peer(s) (response.peers type: %s)",
tracker_type,
url[:80] + "..." if len(url) > 80 else url,
@@ -1677,23 +2285,28 @@ async def announce_to_multiple(
type(result.peers).__name__ if result.peers else "None",
)
elif result is None:
- # CRITICAL FIX: Handle None result (UDP tracker skipped due to missing client)
tracker_type = "UDP" if url.startswith("udp://") else "HTTP/HTTPS"
+ skipped_count += 1
+ skip_detail = _tracker_announce_skip_reason.get() or "unknown_skip"
self.logger.debug(
- "%s tracker %s skipped (UDP tracker client unavailable)",
+ "%s tracker %s skipped (%s)",
tracker_type,
url[:80] + "..." if len(url) > 80 else url,
+ skip_detail,
)
elif isinstance(result, Exception):
tracker_type = "UDP" if url.startswith("udp://") else "HTTP/HTTPS"
failed_trackers.append((url, result))
- # CRITICAL FIX: Log tracker failures at warning level, not debug
+ if id(result) not in failure_tracker_marks:
+ self._handle_tracker_failure(url, failure_reason=str(result))
+ # Note: Log tracker failures at warning level, not debug
# This helps diagnose why peer discovery is failing
error_msg = str(result)
error_type = type(result).__name__
# Enhanced error messages for common failure types
if "timeout" in error_msg.lower() or "TimeoutError" in error_type:
+ timeout_count += 1
self.logger.warning(
"%s tracker %s timed out: %s (tracker may be slow or unreachable)",
tracker_type,
@@ -1703,12 +2316,24 @@ async def announce_to_multiple(
elif (
"connection" in error_msg.lower() or "ConnectionError" in error_type
):
+ connection_error_count += 1
self.logger.warning(
"%s tracker %s connection failed: %s (network issue or tracker down)",
tracker_type,
url[:80] + "..." if len(url) > 80 else url,
error_msg,
)
+ elif (
+ "parse tracker response" in error_msg.lower()
+ or "invalid bencode" in error_msg.lower()
+ ):
+ invalid_payload_count += 1
+ self.logger.warning(
+ "%s tracker %s returned invalid payload: %s",
+ tracker_type,
+ url[:80] + "..." if len(url) > 80 else url,
+ error_msg,
+ )
else:
self.logger.warning(
"%s tracker %s failed: %s (%s)",
@@ -1718,20 +2343,24 @@ async def announce_to_multiple(
error_type,
)
- self.logger.info(
- "✅ ANNOUNCE_TO_MULTIPLE: Multi-tracker announce completed: %d/%d successful, %d total peer(s) discovered (returning %d response(s))",
+ self.logger.debug(
+ "✅ ANNOUNCE_TO_MULTIPLE: Multi-tracker announce completed: %d/%d successful, %d total peer(s) discovered (returning %d response(s), timeouts=%d, connection_errors=%d, invalid_payloads=%d, skipped=%d)",
len(successful_responses),
len(tracker_urls),
total_peers,
len(successful_responses),
+ timeout_count,
+ connection_error_count,
+ invalid_payload_count,
+ skipped_count,
)
- # CRITICAL FIX: Log each successful response's peer count for diagnostics
+ # Note: Log each successful response's peer count for diagnostics
for i, resp in enumerate(successful_responses):
peer_count = (
len(resp.peers) if resp and hasattr(resp, "peers") and resp.peers else 0
)
- self.logger.info(
+ self.logger.debug(
" Response %d: %d peer(s) (type: %s, has_peers_attr: %s)",
i,
peer_count,
@@ -1762,6 +2391,36 @@ async def announce_to_multiple(
len(failed_trackers) - 5,
)
+ if (
+ allow_all_failure_retry
+ and failed_trackers
+ and len(failed_trackers) == len(tracker_urls)
+ and self._all_failures_are_retryable(failed_trackers)
+ ):
+ fallback_urls = self.get_fallback_trackers(
+ exclude_urls=set(tracker_urls + ranked_urls)
+ )
+ if fallback_urls:
+ fallback_urls = fallback_urls[:2]
+ self.logger.warning(
+ "All %d tracker(s) failed; retrying briefly with %d fallback tracker(s)",
+ len(tracker_urls),
+ len(fallback_urls),
+ )
+ await asyncio.sleep(0.15)
+ retry_responses = await self.announce_to_multiple(
+ torrent_data,
+ fallback_urls,
+ port=port,
+ uploaded=uploaded,
+ downloaded=downloaded,
+ left=left,
+ event=event,
+ allow_all_failure_retry=False,
+ )
+ if retry_responses:
+ successful_responses.extend(retry_responses)
+
return successful_responses
async def _announce_to_tracker(
@@ -1772,7 +2431,8 @@ async def _announce_to_tracker(
downloaded: int,
left: Optional[int],
event: str,
- ) -> Optional[TrackerResponse]:
+ _tracker_failure_marks: Optional[dict[int, bool]] = None,
+ ) -> Union[TrackerResponse, None]:
"""Announce to a single tracker.
Returns:
@@ -1800,22 +2460,67 @@ async def _announce_to_tracker(
left,
event,
)
- # CRITICAL FIX: Handle None return (UDP tracker skipped)
+ # Note: Handle None return (UDP tracker skipped)
if result is None:
return None
return result
except TrackerError as e:
# TrackerError already has context, just enhance with tracker type
+ if _tracker_failure_marks is not None:
+ _tracker_failure_marks[id(e)] = True
normalized_url = self._normalize_tracker_url(announce_url)
is_udp = normalized_url.startswith("udp://")
tracker_type = "UDP" if is_udp else "HTTP/HTTPS"
+ tracker_host = urllib.parse.urlparse(normalized_url).hostname or ""
+ error_text = str(e)
+ if self._is_invalid_payload_failure(error_text):
+ # Track malformed tracker payloads as a soft failure:
+ # return an empty peer list instead of hard-failing the entire announce path.
+ self._increment_session_metric(tracker_host, "invalid_payload_count")
+ self._handle_tracker_failure(normalized_url, failure_reason=error_text)
+ self.logger.warning(
+ "Ignoring malformed tracker payload from %s (%s): %s",
+ normalized_url[:100]
+ if len(normalized_url) > 100
+ else normalized_url,
+ tracker_type,
+ error_text,
+ )
+ return TrackerResponse(
+ interval=1800,
+ peers=[],
+ complete=None,
+ incomplete=None,
+ download_url=None,
+ tracker_id=None,
+ warning_message=error_text,
+ )
+ if is_udp and (
+ "HTTP tracker" in error_text or "HTTP fallback" in error_text
+ ):
+ self._increment_session_metric(
+ tracker_host, "http_fallback_failure_count"
+ )
- self.logger.warning(
- "%s tracker announce failed for %s: %s",
- tracker_type,
- normalized_url[:100] if len(normalized_url) > 100 else normalized_url,
- str(e),
- )
+ if is_udp and (
+ "HTTP tracker" in error_text or "HTTP fallback" in error_text
+ ):
+ self.logger.warning(
+ "UDP tracker announce failed for %s after HTTP fallback attempt: %s",
+ normalized_url[:100]
+ if len(normalized_url) > 100
+ else normalized_url,
+ error_text,
+ )
+ else:
+ self.logger.warning(
+ "%s tracker announce failed for %s: %s",
+ tracker_type,
+ normalized_url[:100]
+ if len(normalized_url) > 100
+ else normalized_url,
+ error_text,
+ )
raise
except Exception as e:
# Generic exception - add tracker type context
@@ -1833,7 +2538,10 @@ async def _announce_to_tracker(
)
# Re-raise as TrackerError for consistent error handling
msg = f"{tracker_type} tracker announce failed: {e}"
- raise TrackerError(msg) from e
+ tracker_error = TrackerError(msg)
+ if _tracker_failure_marks is not None:
+ _tracker_failure_marks[id(tracker_error)] = True
+ raise tracker_error from e
def _generate_peer_id(self) -> bytes:
"""Generate a unique peer ID for this client."""
@@ -1858,6 +2566,45 @@ def normalize_tracker_url(self, url: str) -> str:
"""
return self._normalize_tracker_url(url)
+ def _find_http_fallback_url(
+ self, torrent_data: dict[str, Any], udp_tracker_url: str
+ ) -> Union[str, None]:
+ """Find an explicit HTTP(S) fallback tracker from torrent metadata."""
+ announce_list = torrent_data.get("announce_list", [])
+ for tier in announce_list:
+ if not isinstance(tier, list):
+ continue
+ for candidate in tier:
+ if not isinstance(candidate, str):
+ continue
+ try:
+ normalized_candidate = self._normalize_tracker_url(candidate)
+ except Exception:
+ self.logger.debug(
+ "Skipping invalid tracker candidate %r after normalize failed",
+ candidate,
+ exc_info=True,
+ )
+ continue
+ if normalized_candidate == udp_tracker_url:
+ continue
+ if normalized_candidate.startswith(("http://", "https://")):
+ return normalized_candidate
+
+ announce_url = torrent_data.get("announce")
+ if isinstance(announce_url, str):
+ try:
+ normalized_announce = self._normalize_tracker_url(announce_url)
+ except Exception:
+ return None
+ if (
+ normalized_announce != udp_tracker_url
+ and normalized_announce.startswith(("http://", "https://"))
+ ):
+ return normalized_announce
+
+ return None
+
def _normalize_tracker_url(self, url: str) -> str:
"""Normalize and validate tracker URL to prevent malformed URLs.
@@ -1875,6 +2622,14 @@ def _normalize_tracker_url(self, url: str) -> str:
msg = f"Invalid tracker URL: {url}"
raise TrackerError(msg)
+ if "\x00" in url or "\r" in url or "\n" in url or "\t" in url:
+ msg = f"Rejected unsafe tracker URL control characters: {url}"
+ raise TrackerError(msg)
+
+ if len(url) > 2048:
+ msg = "Tracker URL exceeds maximum length for safe parsing"
+ raise TrackerError(msg)
+
# Decode any double-encoded URLs multiple times if needed
# Some torrents may have URLs that are already URL-encoded
max_decode_attempts = 3
@@ -2018,7 +2773,11 @@ def _normalize_tracker_url(self, url: str) -> str:
msg = f"Unsupported tracker URL scheme: {parsed.scheme} in {url}"
raise TrackerError(msg)
- # CRITICAL FIX: Strip paths from UDP URLs
+ if parsed.username is not None or parsed.password is not None:
+ msg = f"Tracker URL contains credentials and is rejected: {url}"
+ raise TrackerError(msg)
+
+ # Note: Strip paths from UDP URLs
# UDP trackers don't use paths (unlike HTTP trackers), but magnet links may include them
if parsed.scheme == "udp" and parsed.path:
# Remove path from UDP URL (e.g., udp://host:port/announce -> udp://host:port)
@@ -2026,7 +2785,7 @@ def _normalize_tracker_url(self, url: str) -> str:
# Re-parse to get updated URL
parsed = urllib.parse.urlparse(url)
- # CRITICAL FIX: Additional validation for UDP URLs
+ # Note: Additional validation for UDP URLs
# Ensure UDP URLs have valid hostname and port
if parsed.scheme == "udp":
if not parsed.hostname:
@@ -2052,6 +2811,37 @@ def _normalize_tracker_url(self, url: str) -> str:
return url
+ def _parse_tracker_crypto_flags(self, tracker_url: str) -> dict[str, str]:
+ """Parse tracker crypto flags from HTTP(S)-only announce URLs."""
+ parsed = urllib.parse.urlparse(tracker_url)
+ if tracker_url_transport_tier(tracker_url) not in {"HTTP", "HTTPS"}:
+ return {}
+
+ parsed_query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
+ crypto_flags: dict[str, str] = {}
+ known_flags = {"supportcrypto", "requirecrypto", "cryptoport"}
+
+ for name in known_flags:
+ value_list = parsed_query.get(name)
+ if value_list:
+ crypto_flags[name] = str(value_list[0])
+
+ for raw_combo in parsed_query.get("crypto_flags", []):
+ for raw_pair in raw_combo.split(","):
+ pair = raw_pair.strip()
+ if not pair:
+ continue
+ if "=" in pair:
+ key, value = pair.split("=", maxsplit=1)
+ else:
+ key, value = pair, "1"
+ key = key.strip().lower()
+ value = value.strip()
+ if key in known_flags:
+ crypto_flags[key] = value
+
+ return crypto_flags
+
def _build_tracker_url(
self,
base_url: str,
@@ -2062,6 +2852,7 @@ def _build_tracker_url(
downloaded: int,
left: int,
event: str,
+ crypto_flags: Optional[dict[str, str]] = None,
) -> str:
"""Build the complete tracker URL with all required parameters.
@@ -2074,19 +2865,20 @@ def _build_tracker_url(
downloaded: Bytes downloaded
left: Bytes left to download
event: Event type
+ crypto_flags: Optional tracker crypto preference flags
Returns:
Complete tracker URL with query parameters
"""
- # CRITICAL FIX: Normalize tracker URL before building query string
+ # Note: Normalize tracker URL before building query string
try:
base_url = self._normalize_tracker_url(base_url)
except TrackerError:
self.logger.exception("Invalid tracker URL: %s", base_url)
raise
- # CRITICAL FIX: URL encode binary parameters correctly
+ # Note: URL encode binary parameters correctly
# BitTorrent spec requires raw binary data to be URL-encoded, not hex-encoded
# Use quote() for binary data, then manually build query string to avoid double-encoding
info_hash_encoded = urllib.parse.quote(info_hash, safe="")
@@ -2102,7 +2894,7 @@ def _build_tracker_url(
f"downloaded={downloaded}",
f"left={left}",
"compact=1",
- "numwant=200", # CRITICAL FIX: Request up to 200 peers (tracker may return fewer)
+ "numwant=200", # Note: Request up to 200 peers (tracker may return fewer)
# This helps with discoverability - more peers = better connectivity
]
@@ -2110,6 +2902,11 @@ def _build_tracker_url(
if event:
query_parts.append(f"event={urllib.parse.quote(event, safe='')}")
+ if crypto_flags:
+ for key, value in sorted(crypto_flags.items()):
+ if key in {"supportcrypto", "requirecrypto", "cryptoport"}:
+ query_parts.append(f"{key}={value}")
+
# Build full URL
separator = "&" if "?" in base_url else "?"
query_string = "&".join(query_parts)
@@ -2135,11 +2932,9 @@ async def _make_request_async(self, url: str) -> bytes:
raise RuntimeError(msg)
# Auto-detect HTTPS and log SSL status
- from urllib.parse import urlparse
-
- parsed = urlparse(url)
- tracker_host = parsed.hostname or ""
- if parsed.scheme == "https":
+ parsed_url = urllib.parse.urlparse(url)
+ tracker_host = parsed_url.hostname or ""
+ if tracker_url_implies_tls(url):
if (
not self.config.security
or not self.config.security.ssl
@@ -2171,16 +2966,7 @@ async def _make_request_async(self, url: str) -> bytes:
connection_reused = getattr(response, "_connection", None) is not None
# Update metrics
- if tracker_host not in self._session_metrics:
- self._session_metrics[tracker_host] = {
- "request_count": 0,
- "total_request_time": 0.0,
- "total_dns_time": 0.0,
- "connection_reuse_count": 0,
- "error_count": 0,
- }
-
- metrics = self._session_metrics[tracker_host]
+ metrics = self._ensure_session_metric_bucket(tracker_host)
metrics["request_count"] += 1
metrics["total_request_time"] += request_time
metrics["total_dns_time"] += dns_time
@@ -2199,26 +2985,35 @@ async def _make_request_async(self, url: str) -> bytes:
msg = f"HTTP {response.status}: {response.reason}"
raise TrackerError(msg)
+ if tracker_host and tracker_url_implies_tls(url):
+ self._verify_tracker_certificate_pin(tracker_host, response)
+
return await response.read()
+ except ssl.SSLError as e:
+ self._increment_session_metric(tracker_host, "error_count")
+ error_category = self._classify_tracker_ssl_error(e)
+ self.logger.exception("Tracker TLS error for %s", url)
+ msg = f"{error_category}: {url}: {e}"
+ raise TrackerError(msg) from e
+ except asyncio.TimeoutError as e:
+ self._increment_session_metric(tracker_host, "error_count")
+ msg = f"HTTP tracker request timed out ({url}): {e}"
+ raise TrackerError(msg) from e
except aiohttp.ClientSSLError as e: # pragma: no cover - SSL error path tested via exception injection in test_make_request_ssl_error_updates_metrics, but coverage tool may not track exception handler execution perfectly
- if tracker_host in self._session_metrics:
- self._session_metrics[tracker_host]["error_count"] += (
- 1 # pragma: no cover - Same context
- )
+ self._increment_session_metric(tracker_host, "error_count")
self.logger.exception("SSL error connecting to tracker %s", url)
- msg = f"SSL handshake failed: {e}"
+ error_category = self._classify_tracker_ssl_error(e)
+ msg = f"{error_category}: {url}: {e}"
raise TrackerError(msg) from e
except aiohttp.ClientError as e: # pragma: no cover - ClientError path tested via exception injection, but coverage tool may not track exception handler execution perfectly
- if tracker_host in self._session_metrics:
- self._session_metrics[tracker_host]["error_count"] += (
- 1 # pragma: no cover - Same context
- )
- # CRITICAL FIX: Provide specific error messages instead of generic "Network error"
+ self._increment_session_metric(tracker_host, "error_count")
+ # Note: Provide specific error messages instead of generic "Network error"
# Enhanced error messages to distinguish HTTP vs UDP tracker failures
error_type = type(e).__name__
parsed_url = urllib.parse.urlparse(url)
scheme = parsed_url.scheme
+ self._record_tracker_resolution_anomaly(tracker_host, scheme, e)
if isinstance(e, aiohttp.ClientConnectorError):
msg = f"HTTP tracker connection failed ({scheme}://{tracker_host}): {e}"
@@ -2235,8 +3030,7 @@ async def _make_request_async(self, url: str) -> bytes:
msg = f"HTTP tracker client error ({scheme}://{tracker_host}, {error_type}): {e}"
raise TrackerError(msg) from e
except Exception as e:
- if tracker_host in self._session_metrics:
- self._session_metrics[tracker_host]["error_count"] += 1
+ self._increment_session_metric(tracker_host, "error_count")
msg = f"Request failed: {e}"
raise TrackerError(msg) from e
@@ -2250,6 +3044,9 @@ def _update_tracker_session(self, url: str, response: TrackerResponse) -> None:
session.interval = response.interval
session.tracker_id = response.tracker_id
session.failure_count = 0 # Reset failure count on success
+ session.failure_streak = 0
+ session.quarantine_until = 0.0
+ session.quarantine_reason = None
# Store statistics from tracker response (announce responses contain complete/incomplete)
# Note: downloaded count is only available in scrape responses, which are handled separately
@@ -2263,7 +3060,9 @@ def _update_tracker_session(self, url: str, response: TrackerResponse) -> None:
if response.complete is not None or response.incomplete is not None:
session.last_scrape_time = time.time()
- def _handle_tracker_failure(self, url: str) -> None:
+ def _handle_tracker_failure(
+ self, url: str, failure_reason: Optional[str] = None
+ ) -> None:
"""Handle tracker failure with exponential backoff and jitter."""
if url not in self.sessions:
self.sessions[url] = TrackerSession(url=url)
@@ -2271,6 +3070,7 @@ def _handle_tracker_failure(self, url: str) -> None:
session = self.sessions[url]
session.failure_count += 1
session.last_failure = time.time()
+ session.failure_streak += 1
# Record failure in health manager
self.health_manager.record_tracker_result(url, False)
@@ -2296,11 +3096,133 @@ def _handle_tracker_failure(self, url: str) -> None:
max_delay,
)
- def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
+ self._apply_tracker_quarantine(
+ session,
+ failure_reason=failure_reason,
+ failure_count=session.failure_streak,
+ )
+
+ @staticmethod
+ def _classify_non_bencode_payload(response_data: bytes) -> Union[str, None]:
+ """Classify tracker payloads that are not valid bencode candidates."""
+ if not response_data:
+ return "empty payload"
+ if not isinstance(response_data, (bytes, bytearray)):
+ return f"non-bytes payload: {type(response_data).__name__}"
+
+ prefix = bytes(response_data).lstrip()[:64].lower()
+ if not prefix:
+ return "whitespace-only payload"
+ if prefix.startswith((b"<", b" Union[int, None]:
+ """Coerce tracker numeric fields to int with strict validation."""
+ if value is None:
+ if allow_missing:
+ return None
+ msg = f"Missing {field_name} in tracker response"
+ if tracker_url:
+ msg = f"{msg} for {tracker_url}"
+ raise TrackerError(msg)
+
+ if isinstance(value, bool):
+ value = int(value)
+
+ if isinstance(value, int):
+ return value
+
+ if isinstance(value, (bytes, bytearray)):
+ try:
+ value = bytes(value).decode("utf-8", errors="ignore").strip()
+ except Exception as exc:
+ msg = (
+ f"Invalid {field_name} in tracker response for {tracker_url}: "
+ f"{value!r}"
+ )
+ raise TrackerError(msg) from exc
+
+ if not isinstance(value, str):
+ msg = (
+ f"Invalid {field_name} type {type(value).__name__} "
+ f"in tracker response for {tracker_url}"
+ )
+ raise TrackerError(msg)
+
+ try:
+ return int(value.strip())
+ except (TypeError, ValueError) as exc:
+ msg = (
+ f"Invalid {field_name} value '{value}' in tracker response for "
+ f"{tracker_url}"
+ )
+ raise TrackerError(msg) from exc
+
+ @staticmethod
+ def _coerce_tracker_peer_port(peer_port_raw: Any) -> int:
+ """Coerce peer port values from tracker peers list."""
+ if isinstance(peer_port_raw, bool):
+ peer_port = int(peer_port_raw)
+ elif isinstance(peer_port_raw, int):
+ peer_port = peer_port_raw
+ elif isinstance(peer_port_raw, (bytes, bytearray)):
+ peer_port_bytes = bytes(peer_port_raw)
+ if len(peer_port_bytes) == 2:
+ peer_port = int.from_bytes(peer_port_bytes, "big")
+ else:
+ peer_port = int(
+ peer_port_bytes.decode("utf-8", errors="ignore").strip()
+ )
+ else:
+ peer_port = int(peer_port_raw)
+
+ if not isinstance(peer_port, int):
+ msg = "port must be int after coercion"
+ raise TypeError(msg)
+ if peer_port <= 0 or peer_port > 65535:
+ msg = f"port out of range: {peer_port}"
+ raise ValueError(msg)
+ return peer_port
+
+ @staticmethod
+ def _coerce_tracker_peer_ip(peer_ip_raw: Any) -> str:
+ """Coerce peer IP values from tracker peers list."""
+ if isinstance(peer_ip_raw, bytes):
+ peer_ip = peer_ip_raw.decode("utf-8", errors="ignore").strip()
+ elif isinstance(peer_ip_raw, bytearray):
+ peer_ip = bytes(peer_ip_raw).decode("utf-8", errors="ignore").strip()
+ elif isinstance(peer_ip_raw, str):
+ peer_ip = peer_ip_raw.strip()
+ else:
+ msg = f"invalid ip type {type(peer_ip_raw).__name__}"
+ raise TypeError(msg)
+
+ if not peer_ip:
+ msg = "empty peer ip"
+ raise ValueError(msg)
+ return peer_ip
+
+ def _parse_response_async(
+ self, response_data: bytes, tracker_url: str = ""
+ ) -> TrackerResponse:
"""Parse tracker response asynchronously.
Args:
response_data: Raw response data from tracker
+ tracker_url: Tracker URL used for context in error messages.
Returns:
TrackerResponse object
@@ -2310,6 +3232,14 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
"""
try:
+ payload_issue = self._classify_non_bencode_payload(response_data)
+ if payload_issue:
+ msg = (
+ f"Invalid tracker payload ({payload_issue}) "
+ f"for {tracker_url or 'unknown tracker'}"
+ )
+ raise TrackerError(msg)
+
# Decode bencoded response
decoder = BencodeDecoder(response_data)
decoded = decoder.decode()
@@ -2330,14 +3260,50 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
raise TrackerError(msg)
# Extract basic fields
- interval = decoded[b"interval"]
+ interval = self._coerce_tracker_int(
+ decoded[b"interval"],
+ "interval",
+ tracker_url=tracker_url,
+ )
peers_data = decoded[b"peers"]
+ tracker_encryption_preference: Optional[str] = None
+ if tracker_url:
+ crypto_flags = self._parse_tracker_crypto_flags(tracker_url)
+
+ def _normalize_crypto_flag_value(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace").strip().lower()
+ return str(value).strip().lower()
+
+ normalized_requirecrypto = _normalize_crypto_flag_value(
+ crypto_flags.get("requirecrypto")
+ )
+ normalized_supportcrypto = _normalize_crypto_flag_value(
+ crypto_flags.get("supportcrypto")
+ )
+ if normalized_requirecrypto in {"1", "true", "yes", "on", "required"}:
+ tracker_encryption_preference = "required"
+ elif normalized_supportcrypto in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ "preferred",
+ }:
+ tracker_encryption_preference = "preferred"
# Parse peers - handle both compact (bytes) and dictionary (list) formats
peers_dict_list: list[dict[str, Any]] = []
if isinstance(peers_data, bytes):
# Compact peer format: 6 bytes per peer (4 bytes IP + 2 bytes port)
peers_dict_list = self._parse_compact_peers(peers_data)
+ if tracker_encryption_preference is not None:
+ for peer_dict in peers_dict_list:
+ peer_dict["_tracker_encryption_preference"] = (
+ tracker_encryption_preference
+ )
elif isinstance(peers_data, list):
# Dictionary format: list of dictionaries with "ip" and "port" keys
for peer_info in peers_data:
@@ -2346,43 +3312,28 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
peer_ip_raw = peer_info.get(b"ip") or peer_info.get("ip")
peer_port_raw = peer_info.get(b"port") or peer_info.get("port")
- # Decode IP if it's bytes
- if isinstance(peer_ip_raw, bytes):
- peer_ip = peer_ip_raw.decode("utf-8", errors="ignore")
- elif isinstance(peer_ip_raw, str):
- peer_ip = peer_ip_raw
- else:
+ try:
+ peer_ip = self._coerce_tracker_peer_ip(peer_ip_raw)
+ peer_port = self._coerce_tracker_peer_port(peer_port_raw)
+ except Exception as exc:
self.logger.warning(
- "Invalid peer IP type in dictionary format: %s, skipping peer",
- type(peer_ip_raw),
+ "Skipping invalid tracker peer from %s: %s (ip=%r, port=%r)",
+ tracker_url,
+ exc,
+ peer_ip_raw,
+ peer_port_raw,
)
continue
- # Convert port to int
- if isinstance(peer_port_raw, (int, bytes)):
- peer_port = (
- int(peer_port_raw)
- if isinstance(peer_port_raw, int)
- else int.from_bytes(peer_port_raw, "big")
- )
- else:
- try:
- peer_port = int(peer_port_raw)
- except (ValueError, TypeError):
- self.logger.warning(
- "Invalid peer port in dictionary format: %s, skipping peer",
- peer_port_raw,
- )
- continue
-
# Validate peer IP and port
- if peer_ip and peer_port and (1 <= peer_port <= 65535):
+ if peer_ip and peer_port:
peers_dict_list.append(
{
"ip": peer_ip,
"port": peer_port,
"peer_source": "tracker", # Mark peers from tracker responses (BEP 27)
"ssl_capable": None, # Unknown until extension handshake
+ "_tracker_encryption_preference": tracker_encryption_preference,
}
)
else:
@@ -2421,6 +3372,11 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
"ssl_capable"
), # None until extension handshake
)
+ if "_tracker_encryption_preference" in peer_dict:
+ with contextlib.suppress(Exception):
+ peer_info.__dict__["_tracker_encryption_preference"] = (
+ peer_dict["_tracker_encryption_preference"]
+ )
# Validate peer info (PeerInfo validator will check IP/port)
if peer_info.port >= 1 and peer_info.port <= 65535 and peer_info.ip:
peer_info_list.append(peer_info)
@@ -2447,6 +3403,18 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
# Extract optional fields
complete = decoded.get(b"complete")
incomplete = decoded.get(b"incomplete")
+ parsed_complete = self._coerce_tracker_int(
+ complete,
+ "complete",
+ tracker_url=tracker_url,
+ allow_missing=True,
+ )
+ parsed_incomplete = self._coerce_tracker_int(
+ incomplete,
+ "incomplete",
+ tracker_url=tracker_url,
+ allow_missing=True,
+ )
download_url = decoded.get(b"download_url")
if download_url and isinstance(download_url, bytes):
download_url = download_url.decode("utf-8")
@@ -2493,19 +3461,19 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
self.health_manager.add_discovered_tracker(tracker_url)
# Enhanced logging for HTTP tracker response
- self.logger.info(
+ self.logger.debug(
"HTTP tracker response parsed: interval=%d, peers=%d (converted to %d PeerInfo objects), complete=%s, incomplete=%s",
interval,
len(peers_dict_list),
len(peer_info_list),
- complete if complete is not None else "N/A",
- incomplete if incomplete is not None else "N/A",
+ parsed_complete if parsed_complete is not None else "N/A",
+ parsed_incomplete if parsed_incomplete is not None else "N/A",
)
- # CRITICAL FIX: IMMEDIATE CONNECTION PATH - Connect peers as soon as they arrive
+ # Note: IMMEDIATE CONNECTION PATH - Connect peers as soon as they arrive
# This bypasses the announce loop and connects peers immediately
if peer_info_list and len(peer_info_list) > 0:
- self.logger.info(
+ self.logger.debug(
"✅ HTTP TRACKER: Response parsed with %d peer(s) - triggering immediate connection",
len(peer_info_list),
)
@@ -2518,6 +3486,11 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
"ip": p.ip,
"port": p.port,
"peer_source": getattr(p, "peer_source", "tracker"),
+ "_tracker_encryption_preference": getattr(
+ p,
+ "_tracker_encryption_preference",
+ None,
+ ),
}
for p in peer_info_list
]
@@ -2534,10 +3507,10 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
)
return TrackerResponse(
- interval=interval,
+ interval=self._coerce_interval(interval),
peers=peer_info_list,
- complete=complete,
- incomplete=incomplete,
+ complete=parsed_complete,
+ incomplete=parsed_incomplete,
download_url=download_url,
tracker_id=tracker_id,
warning_message=warning_message,
@@ -2549,6 +3522,14 @@ def _parse_response_async(self, response_data: bytes) -> TrackerResponse:
msg = f"Failed to parse tracker response: {e}"
raise TrackerError(msg) from e
+ @staticmethod
+ def _coerce_interval(interval: Union[int, None]) -> int:
+ """Validate required tracker interval."""
+ if interval is None:
+ msg = "Missing required tracker interval"
+ raise TrackerError(msg)
+ return interval
+
def _parse_compact_peers(self, peers_data: bytes) -> list[dict[str, Any]]:
"""Parse compact peer format.
@@ -2636,7 +3617,10 @@ async def scrape(self, torrent_data: dict[str, Any]) -> dict[str, Any]:
# Make HTTP request using existing session
try:
- async with self.session.get(scrape_url) as response:
+ response_ctx = self.session.get(scrape_url)
+ if asyncio.iscoroutine(response_ctx):
+ response_ctx = await response_ctx
+ async with response_ctx as response:
if response.status == 200:
data = await response.read()
return self._parse_scrape_response(data, info_hash)
@@ -2657,7 +3641,9 @@ async def scrape(self, torrent_data: dict[str, Any]) -> dict[str, Any]:
self.logger.exception("HTTP scrape failed")
return {}
- def _build_scrape_url(self, info_hash: bytes, announce_url: str) -> Optional[str]:
+ def _build_scrape_url(
+ self, info_hash: bytes, announce_url: str
+ ) -> Union[str, None]:
"""Build scrape URL from tracker URL.
Args:
@@ -2959,7 +3945,7 @@ async def _cleanup_unhealthy_trackers(self):
or (now - metrics.last_attempt > 48 * 3600)
):
unhealthy_trackers.append(url)
- self.logger.info(
+ self.logger.debug(
"Removing unhealthy tracker %s (success_rate=%.2f, consecutive_failures=%d, last_success=%.1fh ago)",
url,
metrics.success_rate,
@@ -3086,6 +4072,7 @@ def _build_tracker_url(
left: int = 0,
event: str = "",
compact: int = 1,
+ crypto_flags: Optional[dict[str, str]] = None,
) -> str:
"""Build tracker URL with parameters."""
params = {
@@ -3107,6 +4094,11 @@ def _build_tracker_url(
value_str = param_val.hex() if isinstance(param_val, bytes) else param_val
query_parts.append(f"{key}={value_str}")
+ if crypto_flags:
+ for key, value in sorted(crypto_flags.items()):
+ if key in {"supportcrypto", "requirecrypto", "cryptoport"}:
+ query_parts.append(f"{key}={value}")
+
query_string = "&".join(query_parts)
separator = "&" if "?" in announce_url else "?"
return f"{announce_url}{separator}{query_string}"
@@ -3131,7 +4123,7 @@ def _make_request(self, url: str) -> bytes:
msg = f"HTTP {e.code}"
raise TrackerError(msg) from e
except urllib.error.URLError as e:
- # CRITICAL FIX: Provide specific error messages instead of generic "Network error"
+ # Note: Provide specific error messages instead of generic "Network error"
error_reason = (
str(e.reason) if hasattr(e, "reason") and e.reason else str(e)
)
@@ -3159,9 +4151,160 @@ def _make_request(self, url: str) -> bytes:
msg = f"Request failed: {e}"
raise TrackerError(msg) from e
- def _parse_response(self, response_data: bytes) -> dict[str, Any]:
+ @staticmethod
+ def _classify_non_bencode_payload(response_data: bytes) -> Union[str, None]:
+ """Classify tracker payloads that are not valid bencode candidates."""
+ if not response_data:
+ return "empty payload"
+ if not isinstance(response_data, (bytes, bytearray)):
+ return f"non-bytes payload: {type(response_data).__name__}"
+
+ prefix = bytes(response_data).lstrip()[:64].lower()
+ if not prefix:
+ return "whitespace-only payload"
+ if prefix.startswith((b"<", b" Union[int, None]:
+ """Coerce tracker numeric fields to int with strict validation."""
+ if value is None:
+ if allow_missing:
+ return None
+ msg = f"Missing {field_name} in tracker response"
+ if tracker_url:
+ msg = f"{msg} for {tracker_url}"
+ raise TrackerError(msg)
+
+ if isinstance(value, bool):
+ value = int(value)
+
+ if isinstance(value, int):
+ return value
+
+ if isinstance(value, (bytes, bytearray)):
+ try:
+ value = bytes(value).decode("utf-8", errors="ignore").strip()
+ except Exception as exc:
+ msg = (
+ f"Invalid {field_name} in tracker response for {tracker_url}: "
+ f"{value!r}"
+ )
+ raise TrackerError(msg) from exc
+
+ if not isinstance(value, str):
+ msg = (
+ f"Invalid {field_name} type {type(value).__name__} "
+ f"in tracker response for {tracker_url}"
+ )
+ raise TrackerError(msg)
+
+ try:
+ return int(value.strip())
+ except (TypeError, ValueError) as exc:
+ msg = (
+ f"Invalid {field_name} value '{value}' in tracker response for "
+ f"{tracker_url}"
+ )
+ raise TrackerError(msg) from exc
+
+ @staticmethod
+ def _coerce_tracker_peer_port(peer_port_raw: Any) -> int:
+ """Coerce peer port values from tracker peers list."""
+ if isinstance(peer_port_raw, bool):
+ peer_port = int(peer_port_raw)
+ elif isinstance(peer_port_raw, int):
+ peer_port = peer_port_raw
+ elif isinstance(peer_port_raw, (bytes, bytearray)):
+ peer_port_bytes = bytes(peer_port_raw)
+ if len(peer_port_bytes) == 2:
+ peer_port = int.from_bytes(peer_port_bytes, "big")
+ else:
+ peer_port = int(
+ peer_port_bytes.decode("utf-8", errors="ignore").strip()
+ )
+ else:
+ peer_port = int(peer_port_raw)
+
+ if not isinstance(peer_port, int):
+ msg = "port must be int after coercion"
+ raise TypeError(msg)
+ if peer_port <= 0 or peer_port > 65535:
+ msg = f"port out of range: {peer_port}"
+ raise ValueError(msg)
+ return peer_port
+
+ @staticmethod
+ def _coerce_tracker_peer_ip(peer_ip_raw: Any) -> str:
+ """Coerce peer IP values from tracker peers list."""
+ if isinstance(peer_ip_raw, bytes):
+ peer_ip = peer_ip_raw.decode("utf-8", errors="ignore").strip()
+ elif isinstance(peer_ip_raw, bytearray):
+ peer_ip = bytes(peer_ip_raw).decode("utf-8", errors="ignore").strip()
+ elif isinstance(peer_ip_raw, str):
+ peer_ip = peer_ip_raw.strip()
+ else:
+ msg = f"invalid ip type {type(peer_ip_raw).__name__}"
+ raise TypeError(msg)
+
+ if not peer_ip:
+ msg = "empty peer ip"
+ raise ValueError(msg)
+ return peer_ip
+
+ def _parse_tracker_crypto_flags(self, tracker_url: str) -> dict[str, str]:
+ """Parse tracker crypto flags from HTTP(S)-only announce URLs."""
+ parsed = urllib.parse.urlparse(tracker_url)
+ if tracker_url_transport_tier(tracker_url) not in {"HTTP", "HTTPS"}:
+ return {}
+
+ parsed_query = urllib.parse.parse_qs(parsed.query, keep_blank_values=True)
+ crypto_flags: dict[str, str] = {}
+ known_flags = {"supportcrypto", "requirecrypto", "cryptoport"}
+
+ for name in known_flags:
+ value_list = parsed_query.get(name)
+ if value_list:
+ crypto_flags[name] = str(value_list[0])
+
+ for raw_combo in parsed_query.get("crypto_flags", []):
+ for raw_pair in raw_combo.split(","):
+ pair = raw_pair.strip()
+ if not pair:
+ continue
+ if "=" in pair:
+ key, value = pair.split("=", maxsplit=1)
+ else:
+ key, value = pair, "1"
+ key = key.strip().lower()
+ value = value.strip()
+ if key in known_flags:
+ crypto_flags[key] = value
+
+ return crypto_flags
+
+ def _parse_response(
+ self, response_data: bytes, tracker_url: str = ""
+ ) -> dict[str, Any]:
"""Parse tracker response."""
try:
+ payload_issue = self._classify_non_bencode_payload(response_data)
+ if payload_issue:
+ msg = f"Invalid tracker payload ({payload_issue}) for tracker response"
+ raise TrackerError(msg)
+
# Decode bencoded response
decoder = BencodeDecoder(response_data)
decoded = decoder.decode()
@@ -3185,36 +4328,92 @@ def _parse_response(self, response_data: bytes) -> dict[str, Any]:
raise TrackerError(msg)
# Extract response data
- interval = decoded[b"interval"]
+ interval = self._coerce_tracker_int(decoded[b"interval"], "interval")
# Parse peers
+ tracker_encryption_preference: Optional[str] = None
+ if tracker_url:
+ crypto_flags = self._parse_tracker_crypto_flags(tracker_url)
+
+ def _normalize_crypto_flag_value(value: Any) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, bytes):
+ return value.decode("utf-8", errors="replace").strip().lower()
+ return str(value).strip().lower()
+
+ normalized_requirecrypto = _normalize_crypto_flag_value(
+ crypto_flags.get("requirecrypto")
+ )
+ normalized_supportcrypto = _normalize_crypto_flag_value(
+ crypto_flags.get("supportcrypto")
+ )
+ if normalized_requirecrypto in {"1", "true", "yes", "on", "required"}:
+ tracker_encryption_preference = "required"
+ elif normalized_supportcrypto in {
+ "1",
+ "true",
+ "yes",
+ "on",
+ "preferred",
+ }:
+ tracker_encryption_preference = "preferred"
+
peers = []
if b"peers" in decoded:
peers_data = decoded[b"peers"]
if isinstance(peers_data, bytes):
# Compact peer format
peers = self._parse_compact_peers(peers_data)
+ if tracker_encryption_preference is not None:
+ for peer_dict in peers:
+ peer_dict["_tracker_encryption_preference"] = (
+ tracker_encryption_preference
+ )
elif isinstance(peers_data, list):
# Dictionary format
for peer_info in peers_data:
if isinstance(peer_info, dict):
- peer_ip = peer_info.get(b"ip", b"").decode(
- "utf-8",
- errors="ignore",
+ peer_ip_raw = peer_info.get(b"ip") or peer_info.get("ip")
+ peer_port_raw = peer_info.get(b"port") or peer_info.get(
+ "port"
)
- peer_port = peer_info.get(b"port", 0)
+
+ try:
+ peer_ip = self._coerce_tracker_peer_ip(peer_ip_raw)
+ peer_port = self._coerce_tracker_peer_port(
+ peer_port_raw
+ )
+ except Exception as exc:
+ self.logger.warning(
+ "Skipping invalid peer entry: %s (ip=%r, port=%r)",
+ exc,
+ peer_ip_raw,
+ peer_port_raw,
+ )
+ continue
+
if peer_ip and peer_port:
peers.append(
{
"ip": peer_ip,
"port": peer_port,
"peer_source": "tracker", # Mark peers from tracker responses (BEP 27)
+ "_tracker_encryption_preference": tracker_encryption_preference,
}
)
# Optional fields
- complete = decoded.get(b"complete")
- incomplete = decoded.get(b"incomplete")
+ parsed_complete = self._coerce_tracker_int(
+ decoded.get(b"complete"),
+ "complete",
+ allow_missing=True,
+ )
+ parsed_incomplete = self._coerce_tracker_int(
+ decoded.get(b"incomplete"),
+ "incomplete",
+ allow_missing=True,
+ )
download_url = (
decoded.get(b"download_url", b"").decode("utf-8", errors="ignore")
if b"download_url" in decoded
@@ -3238,8 +4437,8 @@ def _parse_response(self, response_data: bytes) -> dict[str, Any]:
return {
"interval": interval,
"peers": peers,
- "complete": complete,
- "incomplete": incomplete,
+ "complete": parsed_complete,
+ "incomplete": parsed_incomplete,
"download_url": download_url,
"tracker_id": tracker_id,
"warning_message": warning_message,
@@ -3359,7 +4558,7 @@ def announce(
response_data = self._make_request(tracker_url)
# Parse response
- response = self._parse_response(response_data)
+ response = self._parse_response(response_data, tracker_url=tracker_url)
# Update tracker session
self._update_tracker_session(torrent_data["announce"], response)
diff --git a/ccbt/discovery/tracker_dedupe.py b/ccbt/discovery/tracker_dedupe.py
new file mode 100644
index 00000000..556f6956
--- /dev/null
+++ b/ccbt/discovery/tracker_dedupe.py
@@ -0,0 +1,87 @@
+"""Deduplicate tracker announce URLs that target the same host:port endpoint.
+
+Multiple schemes (https/http/udp) to the same endpoint create redundant announces
+and multiply load on the shared UDP tracker client. We keep the highest-priority
+scheme per endpoint while preserving first-seen ordering of endpoints.
+"""
+
+from __future__ import annotations
+
+from typing import Optional, Tuple
+from urllib.parse import urlparse
+
+# Prefer TLS-capable HTTP when the same host:port is reachable as both.
+_SCHEME_PRIORITY: dict[str, int] = {
+ "https": 3,
+ "http": 2,
+ "udp": 1,
+}
+
+
+def _default_port_for_scheme(scheme: str) -> Optional[int]:
+ s = scheme.lower()
+ if s == "http":
+ return 80
+ if s == "https":
+ return 443
+ return None
+
+
+def tracker_endpoint_key(url: str) -> Optional[Tuple[str, int]]:
+ """Return (host_lower, port) for deduplication, or None if not dedupeable."""
+ try:
+ parsed = urlparse(url.strip())
+ host = (parsed.hostname or "").lower()
+ if not host:
+ return None
+ port = parsed.port
+ if port is None:
+ port = _default_port_for_scheme(parsed.scheme or "")
+ if port is None:
+ return None
+ return (host, int(port))
+ except (TypeError, ValueError):
+ return None
+
+
+def dedupe_tracker_urls_by_host_port(urls: list[str]) -> list[str]:
+ """Collapse URLs that share the same host:port, preferring https > http > udp.
+
+ Order: first occurrence of each endpoint in ``urls`` defines output position.
+ Unparseable URLs are appended in original order (string-deduped).
+ """
+ if not urls:
+ return []
+
+ best_by_endpoint: dict[tuple[str, int], tuple[int, str]] = {}
+ order: list[tuple[str, int]] = []
+ seen_ep: set[tuple[str, int]] = set()
+ unparsed: list[str] = []
+ unparsed_seen: set[str] = set()
+
+ for raw in urls:
+ if not isinstance(raw, str):
+ continue
+ u = raw.strip()
+ if not u:
+ continue
+ key = tracker_endpoint_key(u)
+ if key is None:
+ if u not in unparsed_seen:
+ unparsed_seen.add(u)
+ unparsed.append(u)
+ continue
+ scheme = (urlparse(u).scheme or "").lower()
+ pri = _SCHEME_PRIORITY.get(scheme, 0)
+ if key not in seen_ep:
+ seen_ep.add(key)
+ order.append(key)
+ best_by_endpoint[key] = (pri, u)
+ else:
+ old_pri, _old_url = best_by_endpoint[key]
+ if pri > old_pri:
+ best_by_endpoint[key] = (pri, u)
+
+ out = [best_by_endpoint[k][1] for k in order]
+ out.extend(unparsed)
+ return out
diff --git a/ccbt/discovery/tracker_udp_client.py b/ccbt/discovery/tracker_udp_client.py
index 209367fe..48a876b1 100644
--- a/ccbt/discovery/tracker_udp_client.py
+++ b/ccbt/discovery/tracker_udp_client.py
@@ -1,10 +1,17 @@
"""UDP Tracker Client (BEP 15) for BitTorrent.
-High-performance async UDP tracker communication with retry logic,
-concurrent announces across multiple tracker tiers, and proper error handling.
-
-Supports BEP 15 IPv6 (18-byte peer response when response is from IPv6) and
-optional BEP 41 extensions (URLData) in announce requests.
+The BEP 15 UDP transport is a datagram protocol and has no TLS framing.
+It provides retry, pacing, and transaction validation but does not provide
+HTTP(S)-style confidentiality or certificate validation by design.
+
+In contrast, HTTPS trackers use the HTTP client stack in ``tracker.py``
+(which can use the optional tracker SSL context) and are expected to rely on
+TLS for transport confidentiality.
+
+This module therefore remains explicitly segregated by threat model:
+- **UDP tracker path**: unauthenticated and unencrypted transport channel.
+- **HTTP/HTTPS tracker path**: HTTPS path carries TLS expectations and certificate
+ controls.
"""
from __future__ import annotations
@@ -14,21 +21,40 @@
import logging
import socket
import struct
+import sys
import time
+from collections import deque
from dataclasses import dataclass
from enum import Enum
-from typing import TYPE_CHECKING, Any, Callable, Optional
+from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional, Union
from urllib.parse import urlparse
from ccbt.config.config import get_config
+from ccbt.session.peer_discovery_telemetry import observe_udp_tracker_pending_window
if TYPE_CHECKING:
from ccbt.models import PeerInfo
+# Callback for immediate peer connection after UDP ANNOUNCE (sync or async).
+ImmediatePeersCallback = Callable[
+ [list[dict[str, Any]], str],
+ Union[None, Awaitable[None]],
+]
+
# Error message constants
_ERROR_UDP_TRANSPORT_NOT_INITIALIZED = "UDP transport is not initialized"
+def _is_windows_proactor_loop(loop: asyncio.AbstractEventLoop) -> bool:
+ """Return whether the loop is Windows ProactorEventLoop."""
+ proactor_loop_type = getattr(asyncio, "ProactorEventLoop", None)
+ return bool(
+ sys.platform == "win32"
+ and proactor_loop_type is not None
+ and isinstance(loop, proactor_loop_type)
+ )
+
+
class TrackerAction(Enum):
"""UDP tracker actions."""
@@ -92,7 +118,8 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
Args:
peer_id: Our peer ID (20 bytes)
- test_mode: If True, bypass socket validation for testing. Defaults to False.
+ test_mode: If True, allow construction outside ``get_udp_tracker_client()`` and
+ bypass socket validation (unit tests only). Production code must use the singleton.
"""
self.config = get_config()
@@ -110,21 +137,53 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
self.socket: Optional[asyncio.DatagramProtocol] = None
self.transport: Optional[asyncio.DatagramTransport] = None
self.transaction_counter = 0
+ self._udp_tracker_stale_response_total: int = 0
+ self._udp_tracker_stale_response_by_category: dict[str, int] = {
+ "timeout_suspect": 0,
+ "id_collision": 0,
+ "foreign_tracker": 0,
+ }
# Pending requests
self.pending_requests: dict[int, asyncio.Future] = {}
+ self.pending_immediate_callbacks: dict[int, ImmediatePeersCallback] = {}
+ self._pending_request_timestamps: dict[int, float] = {}
+ self._max_pending_requests: int = 128
+ self._pending_request_stale_after: float = 30.0
+ self._pending_request_budget_base: int = 128
+ self._pending_request_budget_min: int = 8
+ self._pending_request_budget_per_tracker: int = 16
+ self._pending_request_budget_window: float = 60.0
+ self._pending_request_success_history: deque[float] = deque(maxlen=512)
+ self._pending_request_stale_history: deque[float] = deque(maxlen=512)
+ self._adaptive_pending_request_budget: int = self._pending_request_budget_base
+ self._last_budget_refresh: float = 0.0
+ self._budget_refresh_interval: float = 1.0
+ self._pending_cleanup_interval: float = 30.0
+ self._last_pending_cleanup: float = 0.0
+ self._stale_response_transaction_ids: dict[int, float] = {}
+ self._stale_response_allowlist_seconds: float = 30.0
+ self._timeout_warning_summary_window: float = 1.0
+ self._timeout_warning_host_state: dict[str, tuple[float, int]] = {}
+ self._tracker_response_timeout_alpha: float = 0.25
+ self._tracker_response_timeout_ema: dict[str, float] = {}
+ self._tracker_timeout_floor_scale: dict[str, float] = {}
+ self._pending_request_host_by_tid: dict[int, str] = {}
+ self._pending_request_soft_cap_per_host: int = 24
+ self._udp_wait_pacing_load_ratio: float = 0.5
+ self._last_udp_pending_gauge_monotonic: float = 0.0
# Background tasks
self._cleanup_task: Optional[asyncio.Task] = None
- # CRITICAL FIX: Add lock to prevent concurrent socket operations
+ # Note: Add lock to prevent concurrent socket operations
# Windows requires serialized access to UDP sockets to prevent WinError 10022
self._socket_lock: asyncio.Lock = asyncio.Lock()
self._socket_ready: bool = False
self._last_winerror_warning_time: float = 0.0
self._winerror_warning_interval: float = 60.0 # 60 seconds between warnings
- # CRITICAL FIX: Socket health monitoring to prevent aggressive recreation
+ # Note: Socket health monitoring to prevent aggressive recreation
self._socket_error_count: int = 0
self._socket_last_error_time: float = 0.0
self._socket_health_check_interval: float = (
@@ -137,18 +196,42 @@ def __init__(self, peer_id: Optional[bytes] = None, test_mode: bool = False):
self._socket_recreation_count: int = 0
self._last_socket_health_check: float = 0.0
- # CRITICAL FIX: Immediate peer connection callback
- # This allows sessions to connect peers immediately when tracker responses arrive
- # instead of waiting for the announce loop to process them
- self.on_peers_received: Optional[
- Callable[[list[dict[str, Any]], str], None]
- ] = None
-
# Test mode: bypass socket validation for testing
self._test_mode: bool = test_mode
self._xet_chunk_registry: dict[tuple[bytes, Optional[str]], list[PeerInfo]] = {}
self.logger = logging.getLogger(__name__)
+ self._refresh_udp_pending_settings_from_config()
+ if not test_mode and not _udp_singleton_construct_in_progress():
+ msg = (
+ "AsyncUDPTrackerClient must be obtained via get_udp_tracker_client() "
+ "or ComponentFactory.create_udp_tracker_client(); for isolated tests pass "
+ "test_mode=True."
+ )
+ raise RuntimeError(msg)
+
+ def _refresh_udp_pending_settings_from_config(self) -> None:
+ """Apply discovery.* limits for the process-wide UDP tracker singleton."""
+ disc = getattr(self.config, "discovery", None)
+ if disc is None:
+ return
+ with contextlib.suppress(Exception):
+ self._pending_request_soft_cap_per_host = int(
+ getattr(disc, "tracker_udp_pending_soft_cap_per_host", 24)
+ )
+ self._max_pending_requests = int(
+ getattr(disc, "tracker_udp_max_pending_requests", 128)
+ )
+ self._udp_wait_pacing_load_ratio = float(
+ getattr(disc, "tracker_udp_wait_pacing_load_ratio", 0.5)
+ )
+
+ def _maybe_emit_udp_pending_gauge(self) -> None:
+ now = time.monotonic()
+ if now - self._last_udp_pending_gauge_monotonic < 0.25:
+ return
+ self._last_udp_pending_gauge_monotonic = now
+ observe_udp_tracker_pending_window(len(self.pending_requests))
@property
def socket_ready(self) -> bool:
@@ -160,6 +243,218 @@ def socket_ready(self) -> bool:
"""
return self._socket_ready
+ def _record_pending_request_result(
+ self, *, stale: bool, now: Optional[float] = None
+ ) -> None:
+ """Record completion history for adaptive transaction budgeting."""
+ event_time = time.time() if now is None else now
+ if stale:
+ self._pending_request_stale_history.append(event_time)
+ else:
+ self._pending_request_success_history.append(event_time)
+
+ def _mark_stale_transaction(
+ self, transaction_id: int, *, now: Optional[float] = None
+ ) -> None:
+ """Track transaction IDs that are expected to be late and suppress noisy warnings."""
+ self._stale_response_transaction_ids[transaction_id] = (
+ time.time() if now is None else now
+ )
+
+ def _is_expected_stale_transaction(
+ self, transaction_id: int, *, now: Optional[float] = None
+ ) -> bool:
+ """Check if a response transaction ID should be treated as expected stale."""
+ now = time.time() if now is None else now
+ seen_at = self._stale_response_transaction_ids.get(transaction_id)
+ if seen_at is None:
+ return False
+
+ if now - seen_at > self._stale_response_allowlist_seconds:
+ self._stale_response_transaction_ids.pop(transaction_id, None)
+ return False
+ return True
+
+ def _cleanup_stale_response_transaction_ids(
+ self, now: Optional[float] = None
+ ) -> None:
+ """Drop old expected-stale IDs from the suppression allowlist."""
+ current_time = time.time() if now is None else now
+ cutoff = current_time - self._stale_response_allowlist_seconds
+ expired = [
+ tx_id
+ for tx_id, seen_at in self._stale_response_transaction_ids.items()
+ if seen_at < cutoff
+ ]
+ for tx_id in expired:
+ self._stale_response_transaction_ids.pop(tx_id, None)
+
+ def _classify_unmatched_response(
+ self,
+ *,
+ transaction_id: int,
+ addr: tuple[str, int],
+ now: float,
+ ) -> tuple[str, Optional[float]]:
+ """Classify unmatched UDP responses for actionable churn diagnostics."""
+ addr_host = addr[0] if addr else "unknown"
+ addr_port = addr[1] if addr else 0
+ known_tracker_addr = any(
+ session.host == addr_host and int(session.port) == int(addr_port)
+ for session in self.sessions.values()
+ if getattr(session, "host", None) and getattr(session, "port", None)
+ )
+ if not known_tracker_addr:
+ return "foreign_tracker", None
+
+ if self.pending_requests:
+ oldest_pending = min(self._pending_request_timestamps.values(), default=now)
+ age_hint = max(0.0, now - oldest_pending)
+ return "id_collision", age_hint
+
+ stale_seen_at = self._stale_response_transaction_ids.get(transaction_id)
+ if stale_seen_at is not None:
+ return "timeout_suspect", max(0.0, now - stale_seen_at)
+ return "timeout_suspect", None
+
+ def _trim_request_history(self, now: float) -> None:
+ """Trim stale completion history entries from the tracking window."""
+ cutoff = now - self._pending_request_budget_window
+ while (
+ self._pending_request_stale_history
+ and self._pending_request_stale_history[0] < cutoff
+ ):
+ self._pending_request_stale_history.popleft()
+ while (
+ self._pending_request_success_history
+ and self._pending_request_success_history[0] < cutoff
+ ):
+ self._pending_request_success_history.popleft()
+
+ def _get_adaptive_pending_request_budget(self, now: float) -> int:
+ """Calculate adaptive transaction budget from tracker and response history."""
+ if now - self._last_budget_refresh < self._budget_refresh_interval:
+ return self._adaptive_pending_request_budget
+
+ self._last_budget_refresh = now
+
+ connected_trackers = sum(
+ 1
+ for session in self.sessions.values()
+ if session.is_connected
+ or session.last_response_time is not None
+ or session.connection_time > 0
+ )
+ tracker_count = max(1, connected_trackers or len(self.sessions))
+ adaptive_budget = min(
+ self._pending_request_budget_base,
+ max(
+ self._pending_request_budget_min,
+ tracker_count * self._pending_request_budget_per_tracker,
+ ),
+ )
+
+ self._trim_request_history(now)
+ total_samples = len(self._pending_request_stale_history) + len(
+ self._pending_request_success_history
+ )
+ if total_samples >= 6:
+ stale_ratio = len(self._pending_request_stale_history) / total_samples
+ if stale_ratio >= 0.45:
+ adaptive_budget = max(
+ self._pending_request_budget_min, adaptive_budget // 2
+ )
+ elif stale_ratio <= 0.15 and total_samples >= 12:
+ adaptive_budget = min(
+ self._pending_request_budget_base, adaptive_budget + 8
+ )
+
+ if self._socket_error_count > 0:
+ adaptive_budget = max(
+ self._pending_request_budget_min, adaptive_budget // 2
+ )
+
+ self._adaptive_pending_request_budget = adaptive_budget
+ return adaptive_budget
+
+ def _get_effective_pending_request_cap(self) -> int:
+ """Return final transaction cap including manual override."""
+ now = time.time()
+ self._get_adaptive_pending_request_budget(now)
+ effective_cap = min(
+ self._max_pending_requests, self._adaptive_pending_request_budget
+ )
+ if self._max_pending_requests >= self._pending_request_budget_min:
+ return max(self._pending_request_budget_min, effective_cap)
+ return effective_cap
+
+ def _get_tracker_host(self, tracker_host: Optional[str]) -> str:
+ """Normalize tracker host for timeout tracking keys."""
+ if not tracker_host:
+ return "unknown"
+ normalized = tracker_host.strip()
+ return normalized if normalized else "unknown"
+
+ def _get_adaptive_wait_timeout(
+ self,
+ timeout: float,
+ tracker_host: Optional[str],
+ pending_count: int,
+ ) -> float:
+ """Calculate adaptive timeout based on queue pressure and host response history."""
+ if timeout <= 0:
+ return timeout
+ pending_count = max(pending_count, 0)
+
+ effective_cap = self._get_effective_pending_request_cap()
+ queue_pressure = pending_count / effective_cap if effective_cap > 0 else 0.0
+ queue_pressure = max(0.0, min(1.0, queue_pressure))
+
+ # Queue pressure scaling with congestion floor + hysteresis.
+ # Slightly steeper than legacy 0.45 to shorten waits under multiplex load.
+ queue_scale = 1.0 - (0.55 * queue_pressure)
+ host_key = self._get_tracker_host(tracker_host)
+ previous_floor = float(self._tracker_timeout_floor_scale.get(host_key, 0.65))
+ target_floor = 0.65 if queue_pressure < 0.7 else 0.8
+ floor_scale = (0.85 * previous_floor) + (0.15 * target_floor)
+ self._tracker_timeout_floor_scale[host_key] = floor_scale
+ queue_scale = max(floor_scale, queue_scale)
+
+ host_ema = self._tracker_response_timeout_ema.get(host_key)
+ host_scale = 1.0
+ if host_ema is not None and host_ema > 0:
+ host_scale = max(0.75, min(1.75, host_ema / timeout))
+
+ return max(0.5, min(timeout * 2.0, timeout * host_scale * queue_scale))
+
+ def _record_tracker_response_time(
+ self,
+ tracker_host: Optional[str],
+ elapsed: float,
+ ) -> None:
+ """Track EMA of successful tracker response times per host."""
+ if elapsed <= 0:
+ return
+
+ host_key = self._get_tracker_host(tracker_host)
+ previous = self._tracker_response_timeout_ema.get(host_key)
+ if previous is None:
+ self._tracker_response_timeout_ema[host_key] = elapsed
+ else:
+ alpha = self._tracker_response_timeout_alpha
+ self._tracker_response_timeout_ema[host_key] = (
+ 1 - alpha
+ ) * previous + alpha * elapsed
+
+ def _cleanup_stale_pending_requests(self, now: Optional[float] = None) -> int:
+ """Clean up stale pending transactions outside normal response flow."""
+ current_time = now or time.time()
+ return self._prune_stale_pending_requests(
+ now=current_time,
+ timeout=self._pending_request_stale_after,
+ additional_new=0,
+ )
+
async def announce_chunk(
self,
chunk_hash: bytes,
@@ -204,6 +499,8 @@ async def announce_to_tracker_full(
downloaded: int = 0,
left: int = 0,
event: TrackerEvent = TrackerEvent.STARTED,
+ *,
+ on_immediate_peers: Optional[ImmediatePeersCallback] = None,
) -> Optional[
tuple[list[dict[str, Any]], Optional[int], Optional[int], Optional[int]]
]:
@@ -217,32 +514,85 @@ async def announce_to_tracker_full(
downloaded: Bytes downloaded
left: Bytes left
event: Announce event
+ on_immediate_peers: Optional per-announce callback for immediate peer connect (multi-swarm safe).
Returns:
Tuple of (peers, interval, seeders, leechers) or None on error
"""
return await self._announce_to_tracker_full(
- url, torrent_data, port, uploaded, downloaded, left, event
+ url,
+ torrent_data,
+ port,
+ uploaded,
+ downloaded,
+ left,
+ event,
+ on_immediate_peers=on_immediate_peers,
)
async def _call_immediate_connection(
- self, peers: list[dict[str, Any]], tracker_url: str
+ self,
+ peers: list[dict[str, Any]],
+ tracker_url: str,
+ callback: Optional[ImmediatePeersCallback] = None,
) -> None:
"""Call immediate connection callback asynchronously."""
- if self.on_peers_received:
- try:
- # Call the callback - it should be async-safe
- if asyncio.iscoroutinefunction(self.on_peers_received):
- await self.on_peers_received(peers, tracker_url)
- else:
- self.on_peers_received(peers, tracker_url)
- except Exception as e:
- self.logger.warning(
- "Error in immediate peer connection callback: %s",
- e,
- exc_info=True,
+ if callback is None:
+ return
+ try:
+ if asyncio.iscoroutinefunction(callback):
+ await callback(peers, tracker_url) # type: ignore[misc]
+ else:
+ callback(peers, tracker_url) # type: ignore[misc]
+ except Exception as e:
+ self.logger.warning(
+ "Error in immediate peer connection callback: %s",
+ e,
+ exc_info=True,
+ )
+
+ def _trigger_immediate_connection(
+ self,
+ peers: list[dict[str, Any]],
+ tracker_url: str,
+ log_prefix: str,
+ *,
+ callback: Optional[ImmediatePeersCallback] = None,
+ ) -> None:
+ """Trigger immediate peer connection callback in a context-aware way."""
+ if callback is None:
+ return
+ cb = callback
+ try:
+ # Primary path: schedule the callback in the running event loop.
+ asyncio.get_running_loop()
+ task = asyncio.create_task(
+ self._call_immediate_connection(peers, tracker_url, cb)
+ )
+ task.add_done_callback(
+ lambda t: self.logger.debug("%s callback task completed", log_prefix)
+ if t.exception() is None
+ else self.logger.warning(
+ "%s callback task failed: %s",
+ log_prefix,
+ t.exception(),
)
+ )
+ except RuntimeError:
+ # No running loop - execute inline for unit-test / sync paths.
+ if asyncio.iscoroutinefunction(cb):
+ asyncio.run(self._call_immediate_connection(peers, tracker_url, cb))
+ else:
+ try:
+ cb(peers, tracker_url)
+ self.logger.debug("%s callback completed", log_prefix)
+ except Exception as e:
+ self.logger.warning(
+ "%s callback failed: %s",
+ log_prefix,
+ e,
+ )
def _raise_connection_failed(self) -> None:
"""Raise ConnectionError for failed tracker connection."""
@@ -287,7 +637,7 @@ def _validate_socket_ready(self) -> None:
return
if not self._check_socket_health():
- # CRITICAL FIX: Don't recreate socket on transient errors
+ # Note: Don't recreate socket on transient errors
# Only raise error if socket is truly invalid
current_time = time.time()
@@ -322,7 +672,8 @@ async def start(self) -> None:
CRITICAL: Socket must be initialized during daemon startup via start_udp_tracker_client().
Socket recreation is not supported as it breaks session logic.
"""
- # CRITICAL FIX: Assert socket should never be recreated during runtime
+ self._refresh_udp_pending_settings_from_config()
+ # Note: Assert socket should never be recreated during runtime
# If socket is already initialized and healthy, return immediately
# Socket recreation breaks session logic and causes WinError 10022 on Windows
if (
@@ -347,7 +698,7 @@ async def start(self) -> None:
# Use lock to prevent concurrent start() calls
async with self._socket_lock:
- # CRITICAL FIX: Double-check socket health after acquiring lock
+ # Note: Double-check socket health after acquiring lock
if (
self._socket_ready
and self.transport is not None
@@ -359,7 +710,7 @@ async def start(self) -> None:
)
return
- # CRITICAL FIX: Apply exponential backoff to prevent aggressive socket recreation
+ # Note: Apply exponential backoff to prevent aggressive socket recreation
current_time = time.time()
time_since_last_recreation = current_time - self._socket_last_error_time
@@ -383,7 +734,7 @@ async def start(self) -> None:
)
self._socket_last_error_time = current_time
- # CRITICAL FIX: Prevent socket recreation - fail gracefully instead
+ # Note: Prevent socket recreation - fail gracefully instead
# Socket should have been initialized during daemon startup
# Recreation breaks session logic and causes WinError 10022 on Windows
if self.transport is not None and not self.transport.is_closing():
@@ -410,12 +761,10 @@ async def start(self) -> None:
if self.transport is not None:
try:
self.transport.close()
- # CRITICAL FIX: Wait longer on Windows Proactor for socket to fully close
- import sys
-
+ # Note: Wait longer on Windows Proactor for socket to fully close
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
await asyncio.sleep(0.3) # Longer wait for Proactor
else:
await asyncio.sleep(0.1)
@@ -425,7 +774,7 @@ async def start(self) -> None:
self.transport = None
self.socket = None
- # CRITICAL FIX: Only create new socket if transport is None or closing
+ # Note: Only create new socket if transport is None or closing
# This should only happen during initial daemon startup, not during runtime
if self.transport is not None and not self.transport.is_closing():
self.logger.error(
@@ -446,7 +795,7 @@ async def start(self) -> None:
try:
# Set socket options
try:
- # CRITICAL FIX: Add SO_REUSEADDR for Windows socket binding
+ # Note: Add SO_REUSEADDR for Windows socket binding
# This helps prevent "address already in use" errors and improves socket stability
sock.setsockopt(std_socket.SOL_SOCKET, std_socket.SO_REUSEADDR, 1)
@@ -472,9 +821,8 @@ async def start(self) -> None:
except OSError as e:
sock.close()
- # CRITICAL FIX: Enhanced port conflict error handling
+ # Note: Enhanced port conflict error handling
error_code = e.errno if hasattr(e, "errno") else None
- import sys
if sys.platform == "win32":
if error_code == 10048: # WSAEADDRINUSE
@@ -573,7 +921,7 @@ async def start(self) -> None:
if not isinstance(self.socket, UDPTrackerProtocol):
self.logger.warning("Socket protocol may not be properly registered")
- # CRITICAL FIX: Verify socket is actually ready before marking as ready
+ # Note: Verify socket is actually ready before marking as ready
# Perform a health check to ensure socket can send/receive
try:
# Verify transport is not closing
@@ -587,14 +935,12 @@ async def start(self) -> None:
msg = "Socket name not available after creation"
raise RuntimeError(msg)
- # CRITICAL FIX: On Windows, ensure socket is fully initialized in event loop
+ # Note: On Windows, ensure socket is fully initialized in event loop
# before marking as ready. ProactorEventLoop needs more time for UDP sockets.
# Also verify we're using SelectorEventLoop (not ProactorEventLoop) for UDP support
- import sys
-
if sys.platform == "win32":
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
+ is_proactor = _is_windows_proactor_loop(loop)
if is_proactor:
# CRITICAL: ProactorEventLoop has known bugs with UDP (WinError 10022)
# This should not happen if policy was set correctly in __init__.py
@@ -654,18 +1000,16 @@ async def stop(self) -> None:
with contextlib.suppress(asyncio.CancelledError):
await self._cleanup_task
- # CRITICAL FIX: Ensure proper cleanup of transport on Windows
+ # Note: Ensure proper cleanup of transport on Windows
# Close transport and wait for it to fully close before proceeding
if self.transport:
try:
self.transport.close()
- # CRITICAL FIX: On Windows Proactor, wait for transport to fully close
+ # Note: On Windows Proactor, wait for transport to fully close
# This prevents WinError 10022 when socket is reused
- import sys
-
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
# Wait longer for Proactor to release socket resources
await asyncio.sleep(0.3)
elif sys.platform == "win32":
@@ -706,6 +1050,8 @@ async def stop(self) -> None:
for future in self.pending_requests.values():
if not future.done():
future.cancel()
+ self.pending_requests.clear()
+ self._pending_request_timestamps.clear()
self.logger.info("UDP tracker client stopped")
@@ -831,7 +1177,7 @@ async def _announce_to_tracker(
"""
try:
# Parse URL with enhanced error handling
- # CRITICAL FIX: Rename unpacked variable to avoid shadowing the port parameter
+ # Note: Rename unpacked variable to avoid shadowing the port parameter
try:
host, tracker_port = self._parse_udp_url(url)
except ValueError as e:
@@ -845,7 +1191,7 @@ async def _announce_to_tracker(
session = self.sessions[session_key]
- # CRITICAL FIX: Check connection health and refresh if needed
+ # Note: Check connection health and refresh if needed
# Connection IDs expire after 60 seconds, so refresh before announce
# Improved: Refresh earlier (50s) to avoid race conditions and add validation
current_time = time.time()
@@ -895,7 +1241,7 @@ async def _announce_to_tracker(
return []
# Send announce
- # CRITICAL FIX: Pass port parameter (client's external port from NAT manager) to use external port
+ # Note: Pass port parameter (client's external port from NAT manager) to use external port
# This ensures trackers receive the correct port for routing incoming connections
return await self._send_announce(
session,
@@ -927,6 +1273,8 @@ async def _announce_to_tracker_full(
downloaded: int = 0,
left: int = 0,
event: TrackerEvent = TrackerEvent.STARTED,
+ *,
+ on_immediate_peers: Optional[ImmediatePeersCallback] = None,
) -> Optional[
tuple[list[dict[str, Any]], Optional[int], Optional[int], Optional[int]]
]:
@@ -938,7 +1286,7 @@ async def _announce_to_tracker_full(
"""
try:
# Parse URL
- # CRITICAL FIX: Rename unpacked variable to avoid shadowing the port parameter
+ # Note: Rename unpacked variable to avoid shadowing the port parameter
# The port parameter is the client's external port from NAT manager
host, tracker_port = self._parse_udp_url(url)
@@ -977,7 +1325,7 @@ async def _announce_to_tracker_full(
return None
# Send announce and get full response
- # CRITICAL FIX: Pass port parameter (client's external port from NAT manager) to use external port
+ # Note: Pass port parameter (client's external port from NAT manager) to use external port
# This ensures trackers receive the correct port for routing incoming connections
return await self._send_announce_full(
session,
@@ -987,6 +1335,7 @@ async def _announce_to_tracker_full(
downloaded=downloaded,
left=left,
event=event,
+ on_immediate_peers=on_immediate_peers,
)
except (
@@ -1032,7 +1381,7 @@ def _parse_udp_url(self, url: str) -> tuple[str, int]:
if "?" in url:
url = url.split("?", 1)[0]
- # CRITICAL FIX: Handle IPv6 addresses (enclosed in brackets)
+ # Note: Handle IPv6 addresses (enclosed in brackets)
# IPv6 format: [2001:db8::1]:port
if url.startswith("[") and "]" in url:
# Extract IPv6 address and port
@@ -1093,7 +1442,14 @@ def _parse_udp_url(self, url: str) -> tuple[str, int]:
return host, port
- async def _connect_to_tracker(self, session: TrackerSession) -> None:
+ async def _connect_to_tracker(
+ self,
+ session: TrackerSession,
+ *,
+ max_retries: int = 5,
+ retry_delay: float = 1.0,
+ base_timeout: float = 10.0,
+ ) -> None:
"""Connect to a UDP tracker with health check and retry logic.
CRITICAL: Socket must be initialized during daemon startup. Socket recreation
@@ -1102,12 +1458,15 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
# Validate socket is ready before use
self._validate_socket_ready()
- max_retries = 5
- retry_delay = 1.0
+ if max_retries <= 0:
+ max_retries = 1
+ retry_delay = max(retry_delay, 0.0)
+ if base_timeout < 0.0:
+ base_timeout = 0.1
for attempt in range(max_retries):
try:
- # CRITICAL FIX: Health check - reset connection state if stale
+ # Note: Health check - reset connection state if stale
if session.connection_time > 0 and (
time.time() - session.connection_time > 60.0
):
@@ -1149,7 +1508,7 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
msg = "Transport is None after validation"
raise RuntimeError(msg)
- # CRITICAL FIX: Check socket health before send operation
+ # Note: Check socket health before send operation
if not self._check_socket_health():
# Socket appears unhealthy - increment error count
self._socket_error_count += 1
@@ -1172,13 +1531,11 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
msg = "Socket health check failed"
raise ConnectionError(msg)
- # CRITICAL FIX: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
+ # Note: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
# WinError 10022 can occur if socket state is not properly synchronized
- import sys
-
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
# Longer delay for ProactorEventLoop to ensure socket state is synchronized
await asyncio.sleep(0.1) # Increased from 0.01s to 0.1s
@@ -1215,9 +1572,7 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
)
self._socket_error_count = 0
except OSError as send_error:
- # CRITICAL FIX: Improved WinError 10022 detection and handling
- import sys
-
+ # Note: Improved WinError 10022 detection and handling
error_code = getattr(send_error, "winerror", None) or getattr(
send_error, "errno", None
)
@@ -1231,7 +1586,7 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
self._socket_error_count += 1
self._socket_last_error_time = time.time()
- # CRITICAL FIX: Add exponential backoff for WinError 10022
+ # Note: Add exponential backoff for WinError 10022
# Wait before retrying to allow socket to recover
backoff_delay = min(
0.1 * (2 ** min(self._socket_error_count - 1, 4)), 1.0
@@ -1258,10 +1613,10 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
send_error,
)
- # CRITICAL FIX: Wait before retrying to allow socket to recover
+ # Note: Wait before retrying to allow socket to recover
await asyncio.sleep(backoff_delay)
- # CRITICAL FIX: Validate socket state before retrying
+ # Note: Validate socket state before retrying
if (
not self._socket_ready
or self.transport is None
@@ -1315,9 +1670,8 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
raise
# Wait for response with timeout
- # CRITICAL FIX: Reduce timeout when socket errors are occurring
+ # Note: Reduce timeout when socket errors are occurring
# If socket has recent errors, use shorter timeout to fail faster
- base_timeout = 10.0 # Reduced from 20.0s
if self._socket_error_count > 0:
# Reduce timeout when socket is having issues
base_timeout = max(
@@ -1327,16 +1681,23 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
timeout = base_timeout + (
attempt * 2.0
) # 10s base (or less if errors), increase by 2s per retry attempt
+ adaptive_timeout = self._get_adaptive_wait_timeout(
+ timeout=timeout,
+ tracker_host=session.host,
+ pending_count=len(self.pending_requests),
+ )
self.logger.debug(
"Waiting for tracker response from %s:%d (timeout=%.1fs, attempt %d/%d)",
session.host,
session.port,
- timeout,
+ adaptive_timeout,
attempt + 1,
max_retries,
)
response = await self._wait_for_response(
- transaction_id, timeout=timeout
+ transaction_id,
+ timeout=adaptive_timeout,
+ tracker_host=session.host,
)
if response and response.action == TrackerAction.CONNECT:
@@ -1396,9 +1757,7 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
or isinstance(e, (OSError, ConnectionError))
)
- # CRITICAL FIX: Check if this is a transient socket error that shouldn't trigger recreation
- import sys
-
+ # Note: Check if this is a transient socket error that shouldn't trigger recreation
error_code = getattr(e, "winerror", None) or getattr(e, "errno", None)
is_winerror_10022 = (
error_code == 10022
@@ -1452,7 +1811,7 @@ async def _connect_to_tracker(self, session: TrackerSession) -> None:
session.is_connected = False
session.retry_count += 1
session.backoff_delay = min(session.backoff_delay * 2, 60.0)
- # CRITICAL FIX: Enhanced error logging for connection failures
+ # Note: Enhanced error logging for connection failures
self.logger.warning(
"Failed to connect to tracker %s:%d after %d attempts: %s (type: %s, network_error: %s, backoff: %.1fs)",
session.host,
@@ -1478,7 +1837,7 @@ async def _send_announce(
"""Send announce request to tracker."""
try:
# Check if we need to reconnect
- # CRITICAL FIX: Check connection_id is None, connection_time is 0, or connection expired (>60s)
+ # Note: Check connection_id is None, connection_time is 0, or connection expired (>60s)
current_time = time.time()
connection_expired = (
session.connection_id is None
@@ -1522,7 +1881,7 @@ async def _send_announce(
)
return []
- # CRITICAL FIX: Use external port from NAT manager if provided, otherwise use config port
+ # Note: Use external port from NAT manager if provided, otherwise use config port
# The port parameter should be the external port from NAT manager (passed from AnnounceController)
# If None, fallback to internal port but log warning
if port is not None:
@@ -1534,7 +1893,7 @@ async def _send_announce(
session.port,
)
else:
- # CRITICAL FIX: Use listen_port_tcp (or listen_port as fallback) to match actual configured port
+ # Note: Use listen_port_tcp (or listen_port as fallback) to match actual configured port
client_listen_port = int(
self.config.network.listen_port_tcp
or self.config.network.listen_port
@@ -1579,12 +1938,10 @@ async def _send_announce(
msg = "Transport is None after validation"
raise RuntimeError(msg)
- # CRITICAL FIX: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
- import sys
-
+ # Note: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
# Small delay to ensure socket state is synchronized on Windows Proactor
await asyncio.sleep(0.01)
@@ -1594,9 +1951,7 @@ async def _send_announce(
announce_data, (session.host, session.port)
) # pragma: no cover - Network operation, tested via mocking
except OSError as send_error:
- # CRITICAL FIX: Improved WinError 10022 detection and handling (same as connect)
- import sys
-
+ # Note: Improved WinError 10022 detection and handling (same as connect)
error_code = getattr(send_error, "winerror", None) or getattr(
send_error, "errno", None
)
@@ -1610,7 +1965,7 @@ async def _send_announce(
self._socket_error_count += 1
self._socket_last_error_time = time.time()
- # CRITICAL FIX: Add exponential backoff for WinError 10022
+ # Note: Add exponential backoff for WinError 10022
backoff_delay = min(
0.1 * (2 ** min(self._socket_error_count - 1, 4)), 1.0
) # Max 1 second
@@ -1685,7 +2040,7 @@ async def _send_announce(
raise
# Wait for response with timeout
- # CRITICAL FIX: Use adaptive timeout based on connection quality
+ # Note: Use adaptive timeout based on connection quality
# For first announce, use longer timeout. For subsequent announces, use shorter timeout if previous was fast
base_timeout = 30.0 # 30 seconds base timeout
if session.last_announce > 0:
@@ -1705,8 +2060,15 @@ async def _send_announce(
start_time = time.time()
try:
+ adaptive_timeout = self._get_adaptive_wait_timeout(
+ timeout=announce_timeout,
+ tracker_host=session.host,
+ pending_count=len(self.pending_requests),
+ )
response = await self._wait_for_response(
- transaction_id, timeout=announce_timeout
+ transaction_id,
+ timeout=adaptive_timeout,
+ tracker_host=session.host,
) # pragma: no cover - Async network wait, tested separately
# Track response time for adaptive timeout
response_time = time.time() - start_time
@@ -1720,7 +2082,7 @@ async def _send_announce(
"(3) Firewall blocking responses, or (4) Tracker is overloaded",
session.host,
session.port,
- announce_timeout,
+ adaptive_timeout,
response_time,
)
raise
@@ -1728,7 +2090,7 @@ async def _send_announce(
if (
response and response.action == TrackerAction.ANNOUNCE
): # pragma: no cover - Successful response path requires real network or complex async mocking
- # CRITICAL FIX: Log successful announce with peer count
+ # Note: Log successful announce with peer count
peer_count = (
len(response.peers)
if (hasattr(response, "peers") and response.peers)
@@ -1759,7 +2121,7 @@ async def _send_announce(
except (
Exception
) as e: # pragma: no cover - Announce exception, defensive error handling
- # CRITICAL FIX: Enhanced error logging for announce failures
+ # Note: Enhanced error logging for announce failures
self.logger.warning(
"Announce error for tracker %s:%d: %s (type: %s)",
session.host,
@@ -1780,6 +2142,8 @@ async def _send_announce_full(
downloaded: int = 0,
left: int = 0,
event: TrackerEvent = TrackerEvent.STARTED,
+ *,
+ on_immediate_peers: Optional[ImmediatePeersCallback] = None,
) -> Optional[
tuple[list[dict[str, Any]], Optional[int], Optional[int], Optional[int]]
]:
@@ -1834,7 +2198,7 @@ async def _send_announce_full(
)
return None
- # CRITICAL FIX: Use external port from NAT manager if provided, otherwise use config port
+ # Note: Use external port from NAT manager if provided, otherwise use config port
# The port parameter should be the external port from NAT manager (passed from AnnounceController)
# If None, fallback to internal port but log warning
if port is not None:
@@ -1846,7 +2210,7 @@ async def _send_announce_full(
session.port,
)
else:
- # CRITICAL FIX: Use listen_port_tcp (or listen_port as fallback) to match actual configured port
+ # Note: Use listen_port_tcp (or listen_port as fallback) to match actual configured port
client_listen_port = int(
self.config.network.listen_port_tcp
or self.config.network.listen_port
@@ -1891,12 +2255,10 @@ async def _send_announce_full(
msg = "Transport is None after validation"
raise RuntimeError(msg)
- # CRITICAL FIX: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
- import sys
-
+ # Note: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
# Small delay to ensure socket state is synchronized on Windows Proactor
await asyncio.sleep(0.01)
@@ -1906,9 +2268,7 @@ async def _send_announce_full(
announce_data, (session.host, session.port)
) # pragma: no cover - Network operation, tested via mocking
except OSError as send_error:
- # CRITICAL FIX: Improved WinError 10022 detection and handling (same as connect)
- import sys
-
+ # Note: Improved WinError 10022 detection and handling (same as connect)
error_code = getattr(send_error, "winerror", None) or getattr(
send_error, "errno", None
)
@@ -1922,7 +2282,7 @@ async def _send_announce_full(
self._socket_error_count += 1
self._socket_last_error_time = time.time()
- # CRITICAL FIX: Add exponential backoff for WinError 10022
+ # Note: Add exponential backoff for WinError 10022
backoff_delay = min(
0.1 * (2 ** min(self._socket_error_count - 1, 4)), 1.0
) # Max 1 second
@@ -1997,11 +2357,18 @@ async def _send_announce_full(
raise
# Wait for response
- # CRITICAL FIX: Increased timeout from 10s to 30s to match _send_announce
+ # Note: Increased timeout from 10s to 30s to match _send_announce
# Trackers may be slow, especially on first announce
announce_timeout = 30.0 # 30 seconds for announce (matching _send_announce)
response = await self._wait_for_response(
- transaction_id, timeout=announce_timeout
+ transaction_id,
+ timeout=self._get_adaptive_wait_timeout(
+ timeout=announce_timeout,
+ tracker_host=session.host,
+ pending_count=len(self.pending_requests),
+ ),
+ tracker_host=session.host,
+ immediate_peers_callback=on_immediate_peers,
) # pragma: no cover - Async network wait, tested separately
if (
@@ -2042,31 +2409,230 @@ def _get_transaction_id(self) -> int:
self.transaction_counter = (self.transaction_counter + 1) % 65536
return self.transaction_counter
+ def _record_timeout_warning(
+ self,
+ *,
+ now: float,
+ transaction_id: int,
+ timeout: float,
+ pending_count: int,
+ elapsed: float,
+ oldest_age: float,
+ pruned_count: int,
+ tracker_host: Optional[str] = None,
+ ) -> None:
+ """Track timeout warnings and emit batched summaries."""
+ host_key = self._get_tracker_host(tracker_host)
+ timeout_window_start, timeout_count = self._timeout_warning_host_state.get(
+ host_key, (0.0, 0)
+ )
+
+ if timeout_count == 0:
+ self._timeout_warning_host_state[host_key] = (now, 1)
+ self.logger.debug(
+ "Timeout waiting for tracker response (host=%s, transaction_id=%d, timeout=%.1fs). "
+ "Aggregating timeout events for %.1fs windows.",
+ host_key,
+ transaction_id,
+ timeout,
+ self._timeout_warning_summary_window,
+ )
+ return
+
+ timeout_count += 1
+ self._timeout_warning_host_state[host_key] = (
+ timeout_window_start,
+ timeout_count,
+ )
+ if now - timeout_window_start < self._timeout_warning_summary_window:
+ self.logger.debug(
+ "Tracker timeout summary: pending=%d, event_count=%d in %.1fs window",
+ pending_count,
+ timeout_count,
+ now - timeout_window_start,
+ )
+ return
+
+ self.logger.warning(
+ "Tracker response timeout burst for host=%s: %d timeouts in %.1fs (latest tx=%d, timeout=%.1fs, pending=%d, elapsed=%.1fs, oldest_age=%.1fs, pruned=%d)",
+ host_key,
+ timeout_count,
+ now - timeout_window_start,
+ transaction_id,
+ timeout,
+ pending_count,
+ elapsed,
+ oldest_age,
+ pruned_count,
+ )
+ self._timeout_warning_host_state[host_key] = (now, 1)
+
+ def _prune_stale_pending_requests(
+ self, now: float, timeout: float, additional_new: int = 0
+ ) -> int:
+ """Drop stale pending requests and enforce request cap."""
+ effective_cap = self._get_effective_pending_request_cap()
+ cutoff = now - max(timeout, self._pending_request_stale_after)
+ stale_tids = [
+ transaction_id
+ for transaction_id, timestamp in self._pending_request_timestamps.items()
+ if timestamp < cutoff
+ ]
+ pruned_count = 0
+ for transaction_id in stale_tids:
+ future = self.pending_requests.pop(transaction_id, None)
+ self._pending_request_timestamps.pop(transaction_id, None)
+ self.pending_immediate_callbacks.pop(transaction_id, None)
+ self._pending_request_host_by_tid.pop(transaction_id, None)
+ if future is not None and not future.done():
+ future.cancel()
+ self._mark_stale_transaction(transaction_id, now=now)
+ self._record_pending_request_result(stale=True, now=now)
+ self.logger.warning(
+ "Cancelling stale tracker request transaction_id=%d (age=%.1fs)",
+ transaction_id,
+ now - cutoff,
+ )
+ pruned_count += 1
+
+ projected_count = len(self.pending_requests) + additional_new
+ if projected_count > effective_cap:
+ overflow = projected_count - effective_cap
+ oldest = sorted(
+ self._pending_request_timestamps.items(), key=lambda item: item[1]
+ )
+ dropped_tids: list[int] = []
+ for transaction_id, _created_at in oldest[:overflow]:
+ future = self.pending_requests.pop(transaction_id, None)
+ self._pending_request_timestamps.pop(transaction_id, None)
+ self.pending_immediate_callbacks.pop(transaction_id, None)
+ self._pending_request_host_by_tid.pop(transaction_id, None)
+ if future is not None and not future.done():
+ future.cancel()
+ self._mark_stale_transaction(transaction_id, now=now)
+ self._record_pending_request_result(stale=True, now=now)
+ dropped_tids.append(transaction_id)
+ pruned_count += 1
+ self.logger.warning(
+ "Dropping oldest tracker requests to enforce cap: dropped=%s",
+ dropped_tids,
+ )
+ return pruned_count
+
async def _wait_for_response(
self,
transaction_id: int,
timeout: float,
+ tracker_host: Optional[str] = None,
+ *,
+ immediate_peers_callback: Optional[ImmediatePeersCallback] = None,
) -> Optional[TrackerResponse]:
"""Wait for UDP tracker response."""
+ host = self._get_tracker_host(tracker_host)
future = asyncio.Future()
+ now = time.time()
+ start_wait = now
+ host_pending = sum(
+ 1 for h in self._pending_request_host_by_tid.values() if h == host
+ )
+ if host_pending >= self._pending_request_soft_cap_per_host:
+ self.logger.debug(
+ "Tracker host pending soft cap reached: host=%s pending=%d cap=%d",
+ host,
+ host_pending,
+ self._pending_request_soft_cap_per_host,
+ )
+ return None
+ effective_cap_pre = self._get_effective_pending_request_cap()
+ pending_pre = len(self.pending_requests)
+ pace_threshold = self._udp_wait_pacing_load_ratio * effective_cap_pre
+ if effective_cap_pre > 0 and pending_pre > int(pace_threshold):
+ # Pace new waits when the shared UDP client is heavily loaded so responses
+ # can drain before adding more in-flight transactions.
+ half_span = max(1.0, pace_threshold)
+ pressure = min(
+ 1.0,
+ (pending_pre - pace_threshold) / half_span,
+ )
+ await asyncio.sleep(0.04 + 0.12 * pressure)
+ pruned_count = self._prune_stale_pending_requests(
+ now=now, timeout=timeout, additional_new=1
+ )
+ adaptive_timeout = self._get_adaptive_wait_timeout(
+ timeout=timeout,
+ tracker_host=host,
+ pending_count=len(self.pending_requests),
+ )
+ if transaction_id in self.pending_requests:
+ stale_future = self.pending_requests.pop(transaction_id, None)
+ self._pending_request_timestamps.pop(transaction_id, None)
+ self.pending_immediate_callbacks.pop(transaction_id, None)
+ if stale_future is not None and not stale_future.done():
+ stale_future.cancel()
+ self._mark_stale_transaction(transaction_id, now=time.time())
+ self._record_pending_request_result(stale=True, now=time.time())
+ self.logger.debug(
+ "Replacing existing pending transaction_id=%d while waiting for response",
+ transaction_id,
+ )
+ self.logger.debug(
+ "Tracker pending request window: total=%d, pruned=%d, oldest_age=%.1fs",
+ len(self.pending_requests),
+ pruned_count,
+ now - min(self._pending_request_timestamps.values(), default=now)
+ if self._pending_request_timestamps
+ else 0.0,
+ )
self.pending_requests[transaction_id] = future
+ self._pending_request_timestamps[transaction_id] = now
+ self._pending_request_host_by_tid[transaction_id] = host
+ if immediate_peers_callback is not None:
+ self.pending_immediate_callbacks[transaction_id] = immediate_peers_callback
+ else:
+ self.pending_immediate_callbacks.pop(transaction_id, None)
+
+ self._maybe_emit_udp_pending_gauge()
try:
- return await asyncio.wait_for(future, timeout=timeout)
- except asyncio.TimeoutError:
- # CRITICAL FIX: Enhanced logging for timeouts - this is a common failure mode
- self.logger.warning(
- "Timeout waiting for tracker response (transaction_id=%d, timeout=%.1fs). "
- "This may indicate: (1) Tracker is slow/unresponsive, (2) Network issues, "
- "or (3) Firewall blocking responses. "
- "Pending requests: %d",
+ response = await asyncio.wait_for(future, timeout=adaptive_timeout)
+ elapsed = time.time() - start_wait
+ self._record_tracker_response_time(host, elapsed)
+ return response
+ except asyncio.CancelledError:
+ self._mark_stale_transaction(transaction_id, now=time.time())
+ self._record_pending_request_result(stale=True, now=time.time())
+ self.logger.debug(
+ "Tracker response future cancelled for transaction_id=%d",
transaction_id,
- timeout,
- len(self.pending_requests),
+ )
+ return None
+ except asyncio.TimeoutError:
+ # Note: Enhanced logging for timeouts - this is a common failure mode
+ self._record_pending_request_result(stale=True, now=time.time())
+ elapsed = time.time() - start_wait
+ oldest = (
+ now - min(self._pending_request_timestamps.values(), default=now)
+ if self._pending_request_timestamps
+ else 0.0
+ )
+ self._mark_stale_transaction(transaction_id, now=time.time())
+ self._record_timeout_warning(
+ now=time.time(),
+ transaction_id=transaction_id,
+ timeout=adaptive_timeout,
+ pending_count=len(self.pending_requests),
+ elapsed=elapsed,
+ oldest_age=oldest,
+ pruned_count=pruned_count,
+ tracker_host=host,
)
return None
finally:
self.pending_requests.pop(transaction_id, None)
+ self._pending_request_timestamps.pop(transaction_id, None)
+ self.pending_immediate_callbacks.pop(transaction_id, None)
+ self._pending_request_host_by_tid.pop(transaction_id, None)
+ self._cleanup_stale_response_transaction_ids(now=time.time())
@staticmethod
def _is_ipv6_address(addr: tuple[str, int]) -> bool:
@@ -2115,7 +2681,7 @@ def _parse_peers_compact(
if not (1 <= port <= 65535):
invalid_peers += 1
continue
- if not is_ipv6 and ip == "0.0.0.0":
+ if not is_ipv6 and ip == "0.0.0.0": # nosec B104 — reject invalid peer IP, not bind
invalid_peers += 1
continue
peers.append(
@@ -2131,6 +2697,47 @@ def _parse_peers_compact(
self.logger.debug("Error parsing peer at offset %d: %s", i, e)
return (peers, invalid_peers)
+ def _extract_announce_peers(
+ self, data: bytes, _addr: tuple[str, int]
+ ) -> tuple[list[dict[str, Any]], int, int, int]:
+ """Extract peer list from ANNOUNCE response.
+
+ Returns a tuple of (peers, interval, seeders, leechers).
+ """
+ if len(data) < 20:
+ return [], 0, 0, 0
+
+ try:
+ interval = struct.unpack("!I", data[8:12])[0]
+ leechers = struct.unpack("!I", data[12:16])[0]
+ seeders = struct.unpack("!I", data[16:20])[0]
+ except struct.error:
+ self.logger.debug(
+ "Failed to parse ANNOUNCE header from %s:%d",
+ _addr[0] if _addr else "unknown",
+ _addr[1] if _addr else 0,
+ )
+ return [], 0, 0, 0
+
+ peers = []
+ invalid_peers = 0
+ peer_data = data[20:]
+ is_ipv6 = self._is_ipv6_address(_addr)
+ stride = 18 if is_ipv6 else 6
+ if peer_data and len(peer_data) % stride != 0:
+ peer_data = peer_data[: len(peer_data) - (len(peer_data) % stride)]
+
+ if peer_data:
+ peers, invalid_peers = self._parse_peers_compact(peer_data, is_ipv6)
+
+ if invalid_peers > 0:
+ self.logger.debug(
+ "Skipped %d invalid peer(s) from orphaned announce response",
+ invalid_peers,
+ )
+
+ return peers, interval, seeders, leechers
+
@staticmethod
def _build_bep41_options(tracker_url: str) -> bytes:
"""Build BEP 41 extension options (URLData) to append after byte 98 of announce request."""
@@ -2194,20 +2801,65 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
# Enhanced logging for unmatched responses
# This can happen if: (1) Response arrived after timeout, (2) Transaction ID collision,
# or (3) Response from different tracker/client
- self.logger.warning(
- "Received UDP response with transaction_id=%d from %s:%d but no pending request found. "
- "This may indicate: (1) Response arrived after timeout, (2) Transaction ID collision, "
- "or (3) Response from different tracker/client. "
- "Pending transaction IDs: %s (count: %d). Response action: %d",
- transaction_id,
- _addr[0] if _addr else "unknown",
- _addr[1] if _addr else 0,
- sorted(self.pending_requests.keys())[
- :10
- ], # Show first 10 for brevity
- len(self.pending_requests),
- action,
- )
+ now = time.time()
+ if self._is_expected_stale_transaction(transaction_id, now=now):
+ self.logger.debug(
+ "Ignoring stale tracker response for timed-out transaction_id=%d from %s:%d (action=%d)",
+ transaction_id,
+ _addr[0] if _addr else "unknown",
+ _addr[1] if _addr else 0,
+ action,
+ )
+ else:
+ stale_category, age_hint = self._classify_unmatched_response(
+ transaction_id=transaction_id,
+ addr=_addr,
+ now=now,
+ )
+ self._udp_tracker_stale_response_total += 1
+ self._udp_tracker_stale_response_by_category[stale_category] = (
+ self._udp_tracker_stale_response_by_category.get(
+ stale_category, 0
+ )
+ + 1
+ )
+ self.logger.warning(
+ "Received UDP response with transaction_id=%d from %s:%d but no pending request found. "
+ "This may indicate: (1) Response arrived after timeout, (2) Transaction ID collision, "
+ "or (3) Response from different tracker/client. "
+ "Pending transaction IDs: %s (count: %d). Response action: %d. "
+ "stale_category=%s age_hint_s=%s udp_tracker_stale_response_total=%d "
+ "stale_category_totals=%s",
+ transaction_id,
+ _addr[0] if _addr else "unknown",
+ _addr[1] if _addr else 0,
+ sorted(self.pending_requests.keys())[
+ :10
+ ], # Show first 10 for brevity
+ len(self.pending_requests),
+ action,
+ stale_category,
+ f"{age_hint:.1f}" if isinstance(age_hint, float) else "n/a",
+ self._udp_tracker_stale_response_total,
+ self._udp_tracker_stale_response_by_category,
+ )
+ if action == TrackerAction.ANNOUNCE.value:
+ peers, interval, seeders, leechers = self._extract_announce_peers(
+ data, _addr
+ )
+ if peers:
+ tracker_url = f"{_addr[0] if _addr else 'unknown'}:{_addr[1] if _addr else 0}"
+ self.logger.debug(
+ "Late UDP ANNOUNCE (no pending request) for %s "
+ "(transaction_id=%d, peers=%d, interval=%d, seeders=%d, leechers=%d); "
+ "skipping immediate connect (unknown swarm)",
+ tracker_url,
+ transaction_id,
+ len(peers),
+ interval,
+ seeders,
+ leechers,
+ )
return
future = self.pending_requests[transaction_id]
@@ -2226,6 +2878,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
transaction_id=transaction_id,
connection_id=connection_id,
)
+ self._record_pending_request_result(stale=False, now=time.time())
future.set_result(response)
elif action == TrackerAction.ANNOUNCE.value:
@@ -2234,7 +2887,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
leechers = struct.unpack("!I", data[12:16])[0]
seeders = struct.unpack("!I", data[16:20])[0]
- # CRITICAL FIX: Add detailed logging of raw tracker response
+ # Note: Add detailed logging of raw tracker response
# Always log at INFO level for visibility - this is critical for debugging
self.logger.info(
"UDP Tracker ANNOUNCE response from %s:%d: "
@@ -2247,7 +2900,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
len(data),
)
- # CRITICAL FIX: Log FULL raw response data at INFO level for debugging
+ # Note: Log FULL raw response data at INFO level for debugging
# This helps identify if peers are in the response but not being parsed
if len(data) > 0:
# Log first 200 bytes at INFO level, full response at DEBUG
@@ -2266,11 +2919,11 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
)
# Parse peers (compact format)
- # CRITICAL FIX: Improved peer parsing with validation and logging
+ # Note: Improved peer parsing with validation and logging
peers = []
invalid_peers = 0
- # CRITICAL FIX: Log raw response for debugging at INFO level for visibility
+ # Note: Log raw response for debugging at INFO level for visibility
self.logger.info(
"UDP Tracker response parsing: length=%d bytes, action=ANNOUNCE, seeders=%d, leechers=%d",
len(data),
@@ -2328,7 +2981,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
len(data),
)
- # CRITICAL FIX: If tracker reports seeders/leechers but no peer data, log error
+ # Note: If tracker reports seeders/leechers but no peer data, log error
if (seeders > 0 or leechers > 0) and len(data) <= 20:
self.logger.error(
"INCONSISTENCY: Tracker %s:%d reports seeders=%d, leechers=%d but no peer data! "
@@ -2357,7 +3010,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
leechers,
)
else:
- # CRITICAL FIX: Enhanced logging for 0 peers case
+ # Note: Enhanced logging for 0 peers case
peer_data_len = (len(data) - 20) if len(data) > 20 else 0
self.logger.warning(
"Tracker %s:%d responded with 0 valid peers after parsing "
@@ -2394,9 +3047,11 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
seeders=seeders,
peers=peers,
)
+ immediate_cb = self.pending_immediate_callbacks.get(transaction_id)
+ self._record_pending_request_result(stale=False, now=time.time())
future.set_result(response)
- # CRITICAL FIX: IMMEDIATE CONNECTION PATH - Connect peers as soon as they arrive
+ # Note: IMMEDIATE CONNECTION PATH - Connect peers as soon as they arrive
# This bypasses the announce loop and connects peers immediately
if peers and len(peers) > 0:
self.logger.info(
@@ -2404,32 +3059,13 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
len(peers),
transaction_id,
)
- # Call immediate connection callback if registered
- if self.on_peers_received:
- try:
- tracker_url = f"{_addr[0] if _addr else 'unknown'}:{_addr[1] if _addr else 0}"
- # Call callback asynchronously to avoid blocking
- # Store task reference to prevent garbage collection
- task = asyncio.create_task(
- self._call_immediate_connection(peers, tracker_url)
- )
- # Add done callback to log errors if task fails
- task.add_done_callback(
- lambda t: self.logger.debug(
- "Immediate connection callback task completed"
- )
- if t.exception() is None
- else self.logger.warning(
- "Immediate connection callback task failed: %s",
- t.exception(),
- )
- )
- except Exception as e:
- self.logger.warning(
- "Failed to trigger immediate peer connection: %s",
- e,
- exc_info=True,
- )
+ tracker_url = f"{_addr[0] if _addr else 'unknown'}:{_addr[1] if _addr else 0}"
+ self._trigger_immediate_connection(
+ peers,
+ tracker_url,
+ "Immediate connection",
+ callback=immediate_cb,
+ )
elif action == TrackerAction.SCRAPE.value:
# Scrape response format:
@@ -2447,6 +3083,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
downloaded=downloaded,
incomplete=incomplete,
)
+ self._record_pending_request_result(stale=False, now=time.time())
future.set_result(response)
elif action == TrackerAction.ERROR.value:
@@ -2456,6 +3093,7 @@ def handle_response(self, data: bytes, _addr: tuple[str, int]) -> None:
transaction_id=transaction_id,
error_message=error_message,
)
+ self._record_pending_request_result(stale=False, now=time.time())
future.set_result(response)
except Exception as e: # pragma: no cover - Exception handling in response parsing, hard to trigger reliably in tests
@@ -2467,10 +3105,12 @@ async def _cleanup_loop(self) -> None:
"""Background task to clean up old sessions."""
while True: # pragma: no cover - Background loop, tested via cancellation
try:
- await asyncio.sleep(300.0) # Clean every 5 minutes
- await (
- self._cleanup_sessions()
- ) # pragma: no cover - Tested via direct calls
+ await asyncio.sleep(self._pending_cleanup_interval)
+ self._last_pending_cleanup = time.time()
+ now = self._last_pending_cleanup
+ await self._cleanup_sessions()
+ self._cleanup_stale_pending_requests(now=now)
+ self._cleanup_stale_response_transaction_ids(now=now)
except asyncio.CancelledError:
break # pragma: no cover - Cancellation tested separately
except Exception: # pragma: no cover - Exception handling tested separately
@@ -2579,12 +3219,10 @@ async def scrape(self, torrent_data: dict[str, Any]) -> dict[str, Any]:
msg = "Transport is None after validation"
raise RuntimeError(msg)
- # CRITICAL FIX: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
- import sys
-
+ # Note: On Windows ProactorEventLoop, ensure socket is fully ready before sendto
loop = asyncio.get_event_loop()
- is_proactor = isinstance(loop, asyncio.ProactorEventLoop)
- if sys.platform == "win32" and is_proactor:
+ is_proactor = _is_windows_proactor_loop(loop)
+ if is_proactor:
# Small delay to ensure socket state is synchronized on Windows Proactor
await asyncio.sleep(0.01)
@@ -2593,8 +3231,6 @@ async def scrape(self, torrent_data: dict[str, Any]) -> dict[str, Any]:
self.transport.sendto(request_data, tracker_address)
except OSError as send_error:
# Check if this is WinError 10022 (transient on Windows)
- import sys
-
error_code = getattr(send_error, "winerror", None) or getattr(
send_error, "errno", None
)
@@ -2615,7 +3251,15 @@ async def scrape(self, torrent_data: dict[str, Any]) -> dict[str, Any]:
raise
# Wait for response
- response_data = await self._wait_for_response(transaction_id, timeout=10.0)
+ response_data = await self._wait_for_response(
+ transaction_id,
+ timeout=self._get_adaptive_wait_timeout(
+ timeout=10.0,
+ tracker_host=host,
+ pending_count=len(self.pending_requests),
+ ),
+ tracker_host=host,
+ )
if response_data:
# Parse scrape response
@@ -2790,8 +3434,6 @@ def error_received(
Behavior is consistent with DHT and uTP implementations which also only log errors.
"""
- import sys
-
error_code = (
getattr(exc, "winerror", None) if hasattr(exc, "winerror") else None
)
@@ -2837,6 +3479,38 @@ def error_received(
) # pragma: no cover - Logging statement, tested via other paths
-# Global UDP tracker client instance
-# Singleton pattern removed - UDP tracker client is now managed via AsyncSessionManager.udp_tracker_client
-# This ensures proper lifecycle management and prevents socket recreation issues
+# Process-wide UDP tracker client (one bound socket per process; BEP 15).
+_udp_tracker_client_singleton: Optional[AsyncUDPTrackerClient] = None
+# True only while get_udp_tracker_client() is creating the module singleton.
+_udp_singleton_construct_active: bool = False
+
+
+def _udp_singleton_construct_in_progress() -> bool:
+ """Return True while the official singleton constructor path is running."""
+ return _udp_singleton_construct_active
+
+
+def get_udp_tracker_client() -> AsyncUDPTrackerClient:
+ """Return the lazily-created process-wide UDP tracker client."""
+ global _udp_tracker_client_singleton, _udp_singleton_construct_active
+ if _udp_tracker_client_singleton is None:
+ _udp_singleton_construct_active = True
+ try:
+ _udp_tracker_client_singleton = AsyncUDPTrackerClient()
+ finally:
+ _udp_singleton_construct_active = False
+ return _udp_tracker_client_singleton
+
+
+async def shutdown_udp_tracker_client() -> None:
+ """Stop the process-wide client and release the module singleton (idempotent)."""
+ global _udp_tracker_client_singleton
+ if _udp_tracker_client_singleton is not None:
+ await _udp_tracker_client_singleton.stop()
+ _udp_tracker_client_singleton = None
+
+
+def reset_udp_tracker_client_for_testing() -> None:
+ """Clear the module singleton without awaiting stop (tests: call after ``stop()`` or with no start)."""
+ global _udp_tracker_client_singleton
+ _udp_tracker_client_singleton = None
diff --git a/ccbt/discovery/xet_cas.py b/ccbt/discovery/xet_cas.py
index 559b25b7..26a53792 100644
--- a/ccbt/discovery/xet_cas.py
+++ b/ccbt/discovery/xet_cas.py
@@ -597,11 +597,10 @@ async def download_chunk(
# Get extension manager and Xet extension (prefer injected manager)
if self.extension_manager is None:
- from ccbt.extensions.manager import get_extension_manager
-
- extension_manager = get_extension_manager()
- else:
- extension_manager = self.extension_manager
+ error_msg = "XetCAS download requires an injected extension_manager"
+ self.logger.debug(error_msg)
+ raise NotImplementedError(error_msg)
+ extension_manager = self.extension_manager
extension_protocol = extension_manager.get_extension("protocol")
xet_ext = extension_manager.get_extension("xet")
diff --git a/ccbt/discovery/xet_multicast.py b/ccbt/discovery/xet_multicast.py
index 08ac19ef..fe814665 100644
--- a/ccbt/discovery/xet_multicast.py
+++ b/ccbt/discovery/xet_multicast.py
@@ -12,7 +12,9 @@
import socket
import struct
import time
-from typing import Any, Callable, Optional
+from typing import Any, Callable, Optional, cast
+
+from ccbt.discovery.errors import SockRecvfromUnsupportedError
logger = logging.getLogger(__name__)
@@ -228,7 +230,10 @@ async def _listen_for_broadcasts(self) -> None:
break
# Wait for data
- data, addr = await loop.sock_recvfrom(self._socket, 4096)
+ sock_recvfrom = getattr(loop, "sock_recvfrom", None)
+ if sock_recvfrom is None:
+ raise SockRecvfromUnsupportedError
+ data, addr = await cast("Any", sock_recvfrom)(self._socket, 4096)
# Parse message
try:
diff --git a/ccbt/executor/session_adapter.py b/ccbt/executor/session_adapter.py
index 80d9dcf1..13dafceb 100644
--- a/ccbt/executor/session_adapter.py
+++ b/ccbt/executor/session_adapter.py
@@ -958,9 +958,9 @@ async def list_torrents(self) -> list[TorrentStatusResponse]:
status_dict = await self.session_manager.get_status()
torrents = []
for info_hash_hex, status in status_dict.items():
- # Canonical internal uses connected_peers/active_peers; IPC uses num_peers/num_seeds
- num_peers = status.get("connected_peers", status.get("num_peers", 0))
- num_seeds = status.get("active_peers", status.get("num_seeds", 0))
+ # Canonical internal keys were normalized to connected_peers/active_peers.
+ num_peers = int(status.get("connected_peers", 0) or 0)
+ num_seeds = int(status.get("active_peers", 0) or 0)
torrents.append(
TorrentStatusResponse(
info_hash=info_hash_hex,
@@ -982,6 +982,17 @@ async def list_torrents(self) -> list[TorrentStatusResponse]:
), # Output directory where files are saved
pieces_completed=status.get("pieces_completed", 0),
pieces_total=status.get("pieces_total", 0),
+ tracker_status=status.get("tracker_status"),
+ last_tracker_error=status.get("last_tracker_error"),
+ last_error=status.get("last_error"),
+ productive_peers=status.get("productive_peers", 0),
+ requestable_peers=status.get("requestable_peers", 0),
+ handshake_complete_peers=status.get("handshake_complete_peers", 0),
+ extension_capable_peers=status.get("extension_capable_peers", 0),
+ metadata_capable_peers=status.get("metadata_capable_peers", 0),
+ hash_verification_failures=status.get(
+ "hash_verification_failures", 0
+ ),
),
)
return torrents
@@ -996,9 +1007,9 @@ async def get_torrent_status(
if not status:
return None
- # Canonical internal uses connected_peers/active_peers; IPC uses num_peers/num_seeds
- num_peers = status.get("connected_peers", status.get("num_peers", 0))
- num_seeds = status.get("active_peers", status.get("num_seeds", 0))
+ # Canonical internal keys were normalized to connected_peers/active_peers.
+ num_peers = int(status.get("connected_peers", 0) or 0)
+ num_seeds = int(status.get("active_peers", 0) or 0)
return TorrentStatusResponse(
info_hash=info_hash,
name=status.get("name", "Unknown"),
@@ -1017,6 +1028,15 @@ async def get_torrent_status(
), # Output directory where files are saved
pieces_completed=status.get("pieces_completed", 0),
pieces_total=status.get("pieces_total", 0),
+ tracker_status=status.get("tracker_status"),
+ last_tracker_error=status.get("last_tracker_error"),
+ last_error=status.get("last_error"),
+ productive_peers=status.get("productive_peers", 0),
+ requestable_peers=status.get("requestable_peers", 0),
+ handshake_complete_peers=status.get("handshake_complete_peers", 0),
+ extension_capable_peers=status.get("extension_capable_peers", 0),
+ metadata_capable_peers=status.get("metadata_capable_peers", 0),
+ hash_verification_failures=status.get("hash_verification_failures", 0),
)
async def pause_torrent(self, info_hash: str) -> bool:
diff --git a/ccbt/executor/torrent_executor.py b/ccbt/executor/torrent_executor.py
index 2ba8eec7..1a63ea5d 100644
--- a/ccbt/executor/torrent_executor.py
+++ b/ccbt/executor/torrent_executor.py
@@ -14,6 +14,35 @@
class TorrentExecutor(CommandExecutor):
"""Executor for torrent commands."""
+ @staticmethod
+ def _normalize_torrent_status_payload(status: Any) -> dict[str, Any]:
+ """Convert a status payload to a canonical dict with peer aliases.
+
+ Args:
+ status: Torrent status payload from adapter.
+
+ Returns:
+ Dictionary with `connected_peers` and `active_peers` populated.
+ """
+ if status is None:
+ return {}
+ if hasattr(status, "model_dump"):
+ payload = dict(status.model_dump())
+ elif isinstance(status, dict):
+ payload = dict(status)
+ else:
+ payload = {
+ "value": status,
+ }
+
+ payload["connected_peers"] = int(
+ payload.get("connected_peers", payload.get("num_peers", 0)) or 0
+ )
+ payload["active_peers"] = int(
+ payload.get("active_peers", payload.get("num_seeds", 0)) or 0
+ )
+ return payload
+
async def execute(
self,
command: str,
@@ -122,7 +151,7 @@ async def _add_torrent(
logger = logging.getLogger(__name__)
try:
- # CRITICAL FIX: Wrap adapter call in try-except to prevent daemon crashes
+ # Note: Wrap adapter call in try-except to prevent daemon crashes
# Align timeout with IPC server timeout (120s for magnets, 60s for torrents)
# This prevents conflicts between executor and IPC server timeouts
try:
@@ -194,7 +223,10 @@ async def _get_torrent_status(self, info_hash: str) -> CommandResult:
"""Get torrent status."""
try:
status = await self.adapter.get_torrent_status(info_hash)
- return CommandResult(success=True, data={"status": status})
+ return CommandResult(
+ success=True,
+ data={"status": self._normalize_torrent_status_payload(status)},
+ )
except Exception as e:
return CommandResult(success=False, error=str(e))
diff --git a/ccbt/executor/xet_executor.py b/ccbt/executor/xet_executor.py
index bcaa8c12..ed6d417a 100644
--- a/ccbt/executor/xet_executor.py
+++ b/ccbt/executor/xet_executor.py
@@ -523,26 +523,6 @@ async def _set_sync_mode_by_key(
error=f"Failed to set sync mode: {e}",
)
- async def _set_sync_mode_by_key(
- self,
- folder_key: str,
- sync_mode: str,
- source_peers: Optional[list[str]] = None,
- ) -> CommandResult:
- """Set synchronization mode using a canonical folder key."""
- try:
- result = await self.adapter.set_xet_folder_sync_mode(
- folder_key,
- sync_mode,
- source_peers=source_peers,
- )
- return CommandResult(success=True, data=result)
- except Exception as e:
- return CommandResult(
- success=False,
- error=f"Failed to set sync mode: {e}",
- )
-
async def _get_sync_mode(self, folder_path: str) -> CommandResult:
"""Get current synchronization mode for folder."""
try:
diff --git a/ccbt/extensions/dht.py b/ccbt/extensions/dht.py
index 1e81e263..32701310 100644
--- a/ccbt/extensions/dht.py
+++ b/ccbt/extensions/dht.py
@@ -18,6 +18,7 @@
from ccbt.core import bencode
from ccbt.models import PeerInfo
+from ccbt.utils.compat import sha1_compat
from ccbt.utils.events import Event, EventType, emit_event
@@ -529,7 +530,7 @@ def _generate_token(self, info_hash: bytes) -> str:
"""Generate token for peer announcement."""
# Simple token generation - in production, use HMAC with secret key
token_data = self.node_id + info_hash + str(time.time()).encode()
- token = hashlib.sha1(token_data, usedforsecurity=False).hexdigest()[:8]
+ token = sha1_compat(token_data, usedforsecurity=False).hexdigest()[:8]
self.peer_tokens[info_hash] = token
return token
diff --git a/ccbt/extensions/manager.py b/ccbt/extensions/manager.py
index a420f605..8770f791 100644
--- a/ccbt/extensions/manager.py
+++ b/ccbt/extensions/manager.py
@@ -16,6 +16,7 @@
from enum import Enum
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Optional
+from ccbt.config.config import get_config
from ccbt.extensions.compact import CompactPeerLists
from ccbt.extensions.dht import DHTExtension
from ccbt.extensions.fast import FastExtension
@@ -389,12 +390,61 @@ async def handle_pex_message(
pex_ext = self.extensions["pex"]
try:
- if message_type == 0: # Added
- peers = pex_ext.decode_peers_list(data, is_ipv6=False)
- await pex_ext.handle_added_peers(peer_id, peers)
- elif message_type == 1: # Dropped
+ added_peers = []
+ added_v6: list = []
+ dropped_peers = []
+ dropped_v6: list = []
+
+ if data and data[:1] == b"d":
+ try:
+ (
+ added_peers,
+ added_v6,
+ dropped_peers,
+ dropped_v6,
+ ) = pex_ext.decode_bep11_payload(data)
+ except Exception:
+ if message_type in (0, 1):
+ peers = pex_ext.decode_peers_list(data, is_ipv6=False)
+ if message_type == 0:
+ added_peers = peers
+ else:
+ dropped_peers = peers
+ else:
+ raise
+ elif (
+ data
+ and len(data) >= 2
+ and data[0] == message_type
+ and data[1] in (0, 1)
+ ):
+ # Legacy interoperability: old payloads sometimes prepend the ut_pex
+ # message-id byte before the compact peer list.
+ legacy_type = data[1]
+ peers = pex_ext.decode_peers_list(data[2:], is_ipv6=False)
+ if legacy_type == 0:
+ added_peers = peers
+ else:
+ dropped_peers = peers
+ elif data and data[:1] in {b"\x00", b"\x01"}:
+ legacy_type = data[0]
+ peers = pex_ext.decode_peers_list(data[1:], is_ipv6=False)
+ if legacy_type == 0:
+ added_peers = peers
+ else:
+ dropped_peers = peers
+ elif message_type in (0, 1):
+ # Backward compatibility: old compact lists had no flags and no BEP11 keys.
peers = pex_ext.decode_peers_list(data, is_ipv6=False)
- await pex_ext.handle_dropped_peers(peer_id, peers)
+ if message_type == 0:
+ added_peers = peers
+ else:
+ dropped_peers = peers
+
+ if added_peers or added_v6:
+ await pex_ext.handle_added_peers(peer_id, added_peers + added_v6)
+ if dropped_peers or dropped_v6:
+ await pex_ext.handle_dropped_peers(peer_id, dropped_peers + dropped_v6)
self.extension_states["pex"].last_activity = time.time()
@@ -499,6 +549,11 @@ async def handle_ssl_message(
msg_type = data[0]
if msg_type == 0x01: # SSL_REQUEST
+ ssl_config = get_config().security.ssl
+ ssl_ext.set_request_policy(
+ bool(ssl_config.enable_ssl_peers)
+ and bool(ssl_config.ssl_extension_enabled)
+ )
request_id = ssl_ext.decode_request(data)
response = await ssl_ext.handle_request(peer_id, request_id)
self.extension_states["ssl"].last_activity = time.time()
@@ -851,19 +906,12 @@ def get_all_statistics(self) -> dict[str, Any]:
return stats
-# Singleton pattern removed - ExtensionManager is now managed via AsyncSessionManager.extension_manager
-# This ensures proper lifecycle management and prevents conflicts between multiple session managers
-# Deprecated singleton kept for backward compatibility
-_extension_manager: Optional[ExtensionManager] = (
- None # Deprecated - use session_manager.extension_manager
-)
-
-
def get_extension_manager() -> ExtensionManager:
"""Get the global extension manager.
DEPRECATED: Singleton pattern removed. Use session_manager.extension_manager instead.
- This function is kept for backward compatibility but will log a warning.
+ This function is kept for backward compatibility but returns a new manager
+ instance to avoid shared global state.
Returns:
ExtensionManager instance (deprecated - use session_manager.extension_manager)
@@ -874,13 +922,8 @@ def get_extension_manager() -> ExtensionManager:
warnings.warn(
"get_extension_manager() is deprecated. "
"Use session_manager.extension_manager instead. "
- "Singleton pattern removed to ensure proper lifecycle management.",
+ "compatibility instances are now independent.",
DeprecationWarning,
stacklevel=2,
)
- global _extension_manager
- if (
- _extension_manager is None
- ): # pragma: no cover - Singleton initialization, tested via existing instance
- _extension_manager = ExtensionManager()
- return _extension_manager
+ return ExtensionManager()
diff --git a/ccbt/extensions/pex.py b/ccbt/extensions/pex.py
index d234dddc..e17a5f8b 100644
--- a/ccbt/extensions/pex.py
+++ b/ccbt/extensions/pex.py
@@ -13,7 +13,7 @@
import time
from dataclasses import dataclass
from enum import IntEnum
-from typing import Any
+from typing import Any, Optional, Union
from ccbt.models import PeerInfo
from ccbt.utils.events import Event, EventType, emit_event
@@ -54,6 +54,8 @@ def __init__(self):
self.added_peers: set[PEXPeer] = set()
self.dropped_peers: set[PEXPeer] = set()
self.peer_flags: dict[tuple[str, int], int] = {} # (ip, port) -> flags
+ self.FLAG_PREFER_ENCRYPT = 0x01
+ self.FLAG_SEED = 0x02
def encode_compact_peer(self, peer: PEXPeer) -> bytes:
"""Encode peer in compact format."""
@@ -101,18 +103,41 @@ def decode_compact_peer(self, data: bytes, is_ipv6: bool = False) -> PEXPeer:
return PEXPeer(ip=ip, port=port)
- def encode_peers_list(self, peers: list[PEXPeer], _is_ipv6: bool = False) -> bytes:
+ @staticmethod
+ def _is_ipv6_peer(ip: str) -> bool:
+ """Check whether peer IP is IPv6."""
+ try:
+ socket.inet_pton(socket.AF_INET6, ip)
+ return True
+ except OSError:
+ return False
+
+ def encode_peers_list(self, peers: list[PEXPeer], is_ipv6: bool = False) -> bytes:
"""Encode list of peers in compact format."""
if not peers:
return b""
peer_data = b""
for peer in peers:
+ if self._is_ipv6_peer(peer.ip) != is_ipv6:
+ continue
peer_data += self.encode_compact_peer(peer)
return peer_data
- def decode_peers_list(self, data: bytes, is_ipv6: bool = False) -> list[PEXPeer]:
+ def encode_peer_flags(self, peers: list[PEXPeer], is_ipv6: bool = False) -> bytes:
+ """Encode peer flags in compact flag bytes."""
+ if not peers:
+ return b""
+ return bytes(
+ peer.flags & 0xFF
+ for peer in peers
+ if self._is_ipv6_peer(peer.ip) == is_ipv6
+ )
+
+ def decode_peers_list(
+ self, data: bytes, is_ipv6: bool = False, flags: Optional[bytes] = None
+ ) -> list[PEXPeer]:
"""Decode list of peers from compact format."""
peers = []
@@ -120,17 +145,128 @@ def decode_peers_list(self, data: bytes, is_ipv6: bool = False) -> list[PEXPeer]
18 if is_ipv6 else 6
) # 16 bytes IP + 2 bytes port for IPv6, 4 bytes IP + 2 bytes port for IPv4
- for i in range(0, len(data), peer_size):
- if i + peer_size <= len(data):
+ peer_flags = flags or b""
+ for idx, offset in enumerate(range(0, len(data), peer_size)):
+ if offset + peer_size <= len(data):
try:
- peer = self.decode_compact_peer(data[i : i + peer_size], is_ipv6)
- peers.append(peer)
+ peer = self.decode_compact_peer(
+ data[offset : offset + peer_size], is_ipv6
+ )
+ peers.append(
+ PEXPeer(
+ ip=peer.ip,
+ port=peer.port,
+ flags=peer_flags[idx] if idx < len(peer_flags) else 0,
+ )
+ )
except ValueError: # pragma: no cover - Invalid peer data skip, tested via valid peer data
# Skip invalid peer data
continue
return peers
+ def encode_bep11_payload(
+ self,
+ *,
+ added_peers: Optional[list[PEXPeer]] = None,
+ dropped_peers: Optional[list[PEXPeer]] = None,
+ ) -> bytes:
+ """Encode BEP11 payload containing peer list updates."""
+ payload: dict[bytes, bytes] = {}
+
+ added_v4: list[PEXPeer] = []
+ added_v6: list[PEXPeer] = []
+ if added_peers:
+ for peer in added_peers:
+ if self._is_ipv6_peer(peer.ip):
+ added_v6.append(peer)
+ else:
+ added_v4.append(peer)
+
+ dropped_v4: list[PEXPeer] = []
+ dropped_v6: list[PEXPeer] = []
+ if dropped_peers:
+ for peer in dropped_peers:
+ if self._is_ipv6_peer(peer.ip):
+ dropped_v6.append(peer)
+ else:
+ dropped_v4.append(peer)
+
+ if added_v4:
+ payload[b"added"] = self.encode_peers_list(added_v4, is_ipv6=False)
+ payload[b"added.f"] = self.encode_peer_flags(added_v4, is_ipv6=False)
+ if added_v6:
+ payload[b"added6"] = self.encode_peers_list(added_v6, is_ipv6=True)
+ payload[b"added6.f"] = self.encode_peer_flags(added_v6, is_ipv6=True)
+ if dropped_v4:
+ payload[b"dropped"] = self.encode_peers_list(dropped_v4, is_ipv6=False)
+ payload[b"dropped.f"] = self.encode_peer_flags(dropped_v4, is_ipv6=False)
+ if dropped_v6:
+ payload[b"dropped6"] = self.encode_peers_list(dropped_v6, is_ipv6=True)
+ payload[b"dropped6.f"] = self.encode_peer_flags(dropped_v6, is_ipv6=True)
+
+ if not payload:
+ return b""
+
+ from ccbt.core.bencode import BencodeEncoder
+
+ encoder = BencodeEncoder()
+ return encoder.encode(payload)
+
+ def _extract_bep11_bytes(
+ self, payload: dict[Any, Any], key: Union[str, bytes]
+ ) -> bytes:
+ """Extract a bytes field from a BEP11 payload."""
+ if not isinstance(payload, dict):
+ return b""
+ value = payload.get(key)
+ if value is None and isinstance(key, str):
+ value = payload.get(key.encode())
+ if isinstance(value, bytes):
+ return value
+ return b""
+
+ def decode_bep11_payload(
+ self, payload: bytes
+ ) -> tuple[list[PEXPeer], list[PEXPeer], list[PEXPeer], list[PEXPeer]]:
+ """Decode BEP11 payload.
+
+ Returns:
+ Tuple of (added_peers_v4, added_peers_v6, dropped_peers_v4, dropped_peers_v6).
+ """
+ if not payload:
+ return [], [], [], []
+
+ from ccbt.core.bencode import BencodeDecoder
+
+ decoder = BencodeDecoder(payload)
+ decoded = decoder.decode()
+ if not isinstance(decoded, dict):
+ msg = "Invalid BEP11 payload"
+ raise TypeError(msg)
+
+ added_peers = self.decode_peers_list(
+ self._extract_bep11_bytes(decoded, b"added"),
+ is_ipv6=False,
+ flags=self._extract_bep11_bytes(decoded, b"added.f"),
+ )
+ added_v6 = self.decode_peers_list(
+ self._extract_bep11_bytes(decoded, b"added6"),
+ is_ipv6=True,
+ flags=self._extract_bep11_bytes(decoded, b"added6.f"),
+ )
+ dropped_peers = self.decode_peers_list(
+ self._extract_bep11_bytes(decoded, b"dropped"),
+ is_ipv6=False,
+ flags=self._extract_bep11_bytes(decoded, b"dropped.f"),
+ )
+ dropped_v6 = self.decode_peers_list(
+ self._extract_bep11_bytes(decoded, b"dropped6"),
+ is_ipv6=True,
+ flags=self._extract_bep11_bytes(decoded, b"dropped6.f"),
+ )
+ return added_peers, added_v6, dropped_peers, dropped_v6
+
def encode_added_peers(self, peers: list[PEXPeer], is_ipv6: bool = False) -> bytes:
"""Encode added peers message."""
peers_data = self.encode_peers_list(peers, is_ipv6)
@@ -330,12 +466,22 @@ def set_peer_flags(self, ip: str, port: int, flags: int) -> None:
def is_peer_seed(self, ip: str, port: int) -> bool:
"""Check if peer is a seed."""
flags = self.get_peer_flags(ip, port)
- return (flags & 0x01) != 0 # Bit 0 indicates seed
+ return (flags & self.FLAG_SEED) != 0
- def is_peer_connectable(self, ip: str, port: int) -> bool:
- """Check if peer is connectable."""
+ def is_peer_encrypt_preferred(self, ip: str, port: int) -> bool:
+ """Check if peer prefers encryption."""
flags = self.get_peer_flags(ip, port)
- return (flags & 0x02) != 0 # Bit 1 indicates connectable
+ return (flags & self.FLAG_PREFER_ENCRYPT) != 0
+
+ def is_peer_connectable(self, ip: str, port: int) -> bool:
+ """Return whether peer can be considered connectable.
+
+ BEP 11 flags only define encryption preference and seed/upload-only bits.
+ Because connectability is not explicitly represented, this helper currently
+ returns ``True`` for known peers to preserve compatibility.
+ """
+ _ = self.get_peer_flags(ip, port)
+ return True
def get_peer_statistics(self) -> dict[str, Any]:
"""Get PEX statistics."""
@@ -344,11 +490,9 @@ def get_peer_statistics(self) -> dict[str, Any]:
"dropped_peers_count": len(self.dropped_peers),
"total_peers_with_flags": len(self.peer_flags),
"seeds_count": sum(
- 1 for flags in self.peer_flags.values() if (flags & 0x01) != 0
- ),
- "connectable_peers_count": sum(
- 1 for flags in self.peer_flags.values() if (flags & 0x02) != 0
+ 1 for flags in self.peer_flags.values() if (flags & self.FLAG_SEED) != 0
),
+ "connectable_peers_count": len(self.peer_flags),
}
def create_peer_from_info(
@@ -357,12 +501,16 @@ def create_peer_from_info(
is_seed: bool = False,
is_connectable: bool = True,
) -> PEXPeer:
- """Create PEX peer from PeerInfo."""
+ """Create PEX peer from PeerInfo.
+
+ Note: ``is_connectable`` maps to the BEP 11 encryption-preference bit
+ (0x01). PEX has no dedicated connectability bit in this implementation.
+ """
flags = 0
if is_seed:
- flags |= 0x01
+ flags |= self.FLAG_SEED
if is_connectable:
- flags |= 0x02
+ flags |= self.FLAG_PREFER_ENCRYPT
return PEXPeer(ip=peer_info.ip, port=peer_info.port, flags=flags)
diff --git a/ccbt/extensions/protocol.py b/ccbt/extensions/protocol.py
index 415529e1..af494cea 100644
--- a/ccbt/extensions/protocol.py
+++ b/ccbt/extensions/protocol.py
@@ -10,6 +10,7 @@
import struct
import time
+import warnings
from dataclasses import dataclass
from enum import IntEnum
from typing import Any, Callable, Optional
@@ -68,6 +69,16 @@ def _normalize_extension_dict(cls, data: dict[Any, Any]) -> dict[str, Any]:
normalized[key_str] = value
return normalized
+ @staticmethod
+ def _normalize_encryption_preference(value: Any) -> Any:
+ """Normalize top-level extended-handshake encryption preference."""
+ if isinstance(value, bytes):
+ try:
+ return value.decode("utf-8")
+ except UnicodeDecodeError:
+ return value.decode("utf-8", errors="replace")
+ return value
+
@staticmethod
def _coerce_message_id(value: Any) -> Optional[int]:
"""Convert peer-advertised extension IDs to integers when possible."""
@@ -103,6 +114,8 @@ def _build_peer_extension_state(self, extensions: dict[str, Any]) -> dict[str, A
state["reverse_message_map"] = {
message_id: name for name, message_id in message_map.items()
}
+ if "e" in state:
+ state["e"] = self._normalize_encryption_preference(state["e"])
return state
def register_extension(
@@ -324,6 +337,7 @@ async def handle_extension_handshake(
"peer_id": peer_id,
"extensions": self.peer_extensions[peer_id],
"ssl_capable": ssl_supported,
+ "encryption_preference": self.peer_extensions[peer_id].get("e"),
"timestamp": time.time(),
},
),
@@ -379,6 +393,13 @@ def get_peer_extensions(self, peer_id: str) -> dict[str, Any]:
"""Get extensions supported by peer."""
return self.peer_extensions.get(peer_id, {})
+ def get_peer_encryption_preference(self, peer_id: str) -> Optional[Any]:
+ """Get peer encryption preference from extended-handshake `e`."""
+ peer_extensions = self.peer_extensions.get(peer_id, {})
+ if not isinstance(peer_extensions, dict):
+ return None
+ return peer_extensions.get("e")
+
def peer_supports_extension(self, peer_id: str, extension_name: str) -> bool:
"""Check if peer supports specific extension."""
peer_extensions = self.peer_extensions.get(peer_id, {})
@@ -429,30 +450,67 @@ def get_peer_extension_info(
def send_extension_message(
self,
- _peer_id: str,
- _extension_name: str,
+ extension_name: str,
payload: bytes,
+ peer_id: Optional[str] = None,
+ local_fallback: bool = False,
) -> bytes:
- """Send extension message to peer."""
- if _extension_name not in self.extensions:
- msg = f"Extension '{_extension_name}' not registered"
- raise ValueError(msg)
+ """Build a peer extension message payload with peer-aware routing IDs.
+
+ This method is now peer-aware and prefers the peer-advertised extension ID
+ for the provided peer. If ``peer_id`` is omitted, it falls back to the
+ local extension ID to preserve compatibility for code paths that are not
+ peer-specific.
+
+ If ``peer_id`` is provided and ``local_fallback`` is False, this method
+ requires a peer-advertised ID and raises when the peer has not advertised
+ the extension. Set ``local_fallback`` to True only when you explicitly want
+ that behavior.
- extension_info = self.extensions[_extension_name]
- return self.encode_extension_message(extension_info.message_id, payload)
+ Args:
+ extension_name: Extension name to send.
+ payload: Payload bytes to send.
+ peer_id: Peer identifier (for peer-specific extension ID lookup).
+ local_fallback: If True, use local extension ID when peer mapping is absent.
- def create_extension_handler(self, _extension_name: str) -> Callable:
- """Create extension handler function."""
+ Returns:
+ Encoded extension message bytes.
- def handler(
- peer_id: str, payload: bytes
- ) -> (
- None
- ): # pragma: no cover - Default handler stub, tested via actual handlers
- # Default handler - can be overridden
- pass
+ """
+ if extension_name not in self.extensions:
+ msg = f"Extension '{extension_name}' not registered"
+ raise ValueError(msg)
+
+ extension_info = self.extensions[extension_name]
+ if peer_id is None:
+ warnings.warn(
+ "send_extension_message is deprecated for direct local-only usage. "
+ "Pass peer_id to safely use the peer-advertised extension ID.",
+ DeprecationWarning,
+ stacklevel=2,
+ )
+ outgoing_message_id: Optional[int]
+ if peer_id is None:
+ outgoing_message_id = extension_info.message_id
+ else:
+ outgoing_message_id = self.get_peer_message_id(peer_id, extension_name)
+ if outgoing_message_id is None and local_fallback:
+ outgoing_message_id = extension_info.message_id
+ if outgoing_message_id is None:
+ msg = f"Extension '{extension_name}' has no id for peer '{peer_id}'"
+ raise ValueError(msg)
+ return self.encode_extension_message(outgoing_message_id, payload)
- return handler
+ def send_extension_message_for_peer(
+ self,
+ peer_id: str,
+ extension_name: str,
+ payload: bytes,
+ ) -> bytes:
+ """Build a peer extension message payload using the peer-advertised ID."""
+ return self.send_extension_message(
+ extension_name, payload, peer_id=peer_id, local_fallback=False
+ )
def register_message_handler(self, message_id: int, handler: Callable) -> None:
"""Register message handler for specific message ID."""
diff --git a/ccbt/extensions/ssl.py b/ccbt/extensions/ssl.py
index 0abc56a5..5713c6c7 100644
--- a/ccbt/extensions/ssl.py
+++ b/ccbt/extensions/ssl.py
@@ -1,18 +1,25 @@
-"""SSL/TLS Extension Protocol (BEP 47) implementation.
+"""Experimental peer TLS extension (BEP 10 extension protocol framing).
+
+This is **not** BEP 47 (BEP 47 defines padding files and extended file attributes).
+The client uses the LTEP/BEP-10 extension namespace to negotiate opportunistic
+TLS **after** the BitTorrent handshake. This provides transport confidentiality
+only when both peers cooperate; it does **not** authenticate peers or replace
+MSE/PE (BEP 3) traffic obfuscation.
Provides support for:
-- SSL/TLS negotiation after BitTorrent handshake
-- Extension protocol-based SSL upgrade
-- Opportunistic encryption
+- TLS negotiation after BitTorrent handshake
+- Extension-protocol-framed upgrade messages
+- Opportunistic encryption (config-gated)
"""
from __future__ import annotations
+import asyncio
import struct
import time
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from enum import IntEnum
-from typing import Any, Optional
+from typing import Any, Callable, Optional
from ccbt.utils.events import Event, EventType, emit_event
@@ -34,15 +41,30 @@ class SSLNegotiationState:
state: str # "idle", "requested", "accepted", "rejected"
timestamp: float
request_id: Optional[int] = None
+ completion_event: asyncio.Event = field(default_factory=asyncio.Event)
class SSLExtension:
- """SSL/TLS Extension implementation (BEP 47)."""
+ """Experimental peer TLS extension (BEP 10), not BEP 47 file attributes."""
def __init__(self):
"""Initialize SSL Extension."""
self.negotiation_states: dict[str, SSLNegotiationState] = {}
self.request_counter = 0
+ self._request_policy: Callable[[str], bool] | bool = True
+
+ def set_request_policy(self, policy: Callable[[str], bool] | bool | None) -> None:
+ """Configure how inbound SSL requests should be handled."""
+ self._request_policy = True if policy is None else policy
+
+ def _is_request_allowed(self, peer_id: str) -> bool:
+ policy = self._request_policy
+ if isinstance(policy, bool):
+ return policy
+ try:
+ return bool(policy(peer_id))
+ except Exception:
+ return False
def encode_handshake(self) -> dict[str, Any]:
"""Encode SSL extension handshake data.
@@ -234,24 +256,23 @@ async def handle_request(self, peer_id: str, request_id: int) -> bytes:
Response message (accept or reject)
"""
- # Update negotiation state
- self.negotiation_states[peer_id] = SSLNegotiationState(
+ # Update negotiation state and make it observable for outbound waiters.
+ state = SSLNegotiationState(
peer_id=peer_id,
state="requested",
timestamp=time.time(),
request_id=request_id,
)
-
- # For now, always accept (can be configured later)
- # TODO: Add configuration option to accept/reject based on settings
- accepted = True
-
- if accepted:
- self.negotiation_states[peer_id].state = "accepted"
- response = self.encode_accept(request_id)
- else: # pragma: no cover - Unreachable: accepted is hardcoded to True (TODO: make configurable)
- self.negotiation_states[peer_id].state = "rejected"
- response = self.encode_reject(request_id)
+ self.negotiation_states[peer_id] = state
+
+ accepted = self._is_request_allowed(peer_id)
+ state.state = "accepted" if accepted else "rejected"
+ state.completion_event.set()
+ response = (
+ self.encode_accept(request_id)
+ if accepted
+ else self.encode_reject(request_id)
+ )
# Emit event
await emit_event(
@@ -279,10 +300,19 @@ async def handle_response(
accepted: Whether SSL upgrade was accepted
"""
- if peer_id in self.negotiation_states:
- state = self.negotiation_states[peer_id]
- if state.request_id == request_id:
- state.state = "accepted" if accepted else "rejected"
+ state = self.negotiation_states.get(peer_id)
+ if state is None:
+ state = SSLNegotiationState(
+ peer_id=peer_id,
+ state="accepted" if accepted else "rejected",
+ timestamp=time.time(),
+ request_id=request_id,
+ )
+ self.negotiation_states[peer_id] = state
+
+ if state.request_id == request_id:
+ state.state = "accepted" if accepted else "rejected"
+ state.completion_event.set()
# Emit event
await emit_event(
diff --git a/ccbt/extensions/webseed.py b/ccbt/extensions/webseed.py
index 908d67f0..3eae9262 100644
--- a/ccbt/extensions/webseed.py
+++ b/ccbt/extensions/webseed.py
@@ -103,7 +103,7 @@ async def stop(self) -> None:
try:
if not self.session.closed:
await self.session.close()
- # CRITICAL FIX: Wait for session to fully close (especially on Windows)
+ # Note: Wait for session to fully close (especially on Windows)
# This prevents "Unclosed client session" warnings
import sys
@@ -112,7 +112,7 @@ async def stop(self) -> None:
else:
await asyncio.sleep(0.1)
- # CRITICAL FIX: Close connector explicitly to ensure complete cleanup
+ # Note: Close connector explicitly to ensure complete cleanup
# This is especially important on Windows where connector cleanup can be delayed
if hasattr(self.session, "connector") and self.session.connector:
connector = self.session.connector
@@ -127,7 +127,7 @@ async def stop(self) -> None:
self.logger.debug("Error closing connector: %s", e)
except Exception as e:
self.logger.debug("Error closing WebSeed session: %s", e)
- # CRITICAL FIX: Even if close() fails, try to clean up connector
+ # Note: Even if close() fails, try to clean up connector
try:
if hasattr(self.session, "connector") and self.session.connector:
connector = self.session.connector
diff --git a/ccbt/i18n/fill_english.py b/ccbt/i18n/fill_english.py
index 3520cf37..ab71e8bc 100644
--- a/ccbt/i18n/fill_english.py
+++ b/ccbt/i18n/fill_english.py
@@ -2,38 +2,37 @@
from __future__ import annotations
-import re
from pathlib import Path
+from typing import Final, Optional
-from rich.console import Console
+from ccbt.i18n.po_parse import iter_po_entries, render_po_entry
-po_file = Path(__file__).parent / "locales" / "en" / "LC_MESSAGES" / "ccbt.po"
+PO_FILE: Final[Path] = (
+ Path(__file__).parent / "locales" / "en" / "LC_MESSAGES" / "ccbt.po"
+)
-with open(po_file, encoding="utf-8") as f:
- content = f.read()
+def _fill_english(po_file: Path) -> list[str]:
+ """Return updated PO file lines where empty English msgstrs are populated."""
+ lines: list[str] = []
+ for entry in iter_po_entries(po_file):
+ entry_msgstr = entry.msgid if entry.msgid and not entry.msgstr else entry.msgstr
+ lines.extend(render_po_entry(entry.msgid, entry_msgstr))
+ lines.append("")
+ return lines
-# Replace empty msgstr with msgid value
-def replace_empty_msgstr(match):
- """Replace empty msgstr with msgid value in .po files.
- Args:
- match: Regex match object containing the msgid
+def fill_english(po_file: Optional[Path] = None) -> None:
+ """Fill empty English msgstr fields from msgid."""
+ target = po_file or PO_FILE
+ lines = _fill_english(target)
+ target.write_text("\n".join(lines) + "\n", encoding="utf-8")
- Returns:
- Formatted string with msgid and msgstr set to the same value
- """
- msgid = match.group(1)
- return f'msgid "{msgid}"\nmsgstr "{msgid}"'
+def main() -> None:
+ """Run PO English fallback completion against the default locale file."""
+ fill_english()
-# Pattern to match msgid followed by empty msgstr
-pattern = r'msgid "([^"]+)"\nmsgstr ""'
-content = re.sub(pattern, replace_empty_msgstr, content)
-
-with open(po_file, "w", encoding="utf-8") as f:
- f.write(content)
-
-console = Console()
-console.print(f"[green]✓[/green] Filled English translations in {po_file}")
+if __name__ == "__main__":
+ main()
diff --git a/ccbt/i18n/locale_data/__init__.py b/ccbt/i18n/locale_data/__init__.py
new file mode 100644
index 00000000..d2712785
--- /dev/null
+++ b/ccbt/i18n/locale_data/__init__.py
@@ -0,0 +1 @@
+"""Locale data artifacts (JSON supplements and trilingual bundles)."""
diff --git a/ccbt/i18n/locale_data/emit_es_val_from_lines.py b/ccbt/i18n/locale_data/emit_es_val_from_lines.py
new file mode 100644
index 00000000..df7e390f
--- /dev/null
+++ b/ccbt/i18n/locale_data/emit_es_val_from_lines.py
@@ -0,0 +1,39 @@
+"""Build ``es_val_N.json`` from ``es_val_N.jsonl`` (one JSON string per line)."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+
+def main() -> None:
+ if len(sys.argv) != 2:
+ msg = "usage: emit_es_val_from_lines.py (N in 0..5)"
+ raise SystemExit(msg)
+ n = int(sys.argv[1])
+ here = Path(__file__).resolve().parent
+ root = here.parents[3]
+ jsonl_path = here / f"es_val_{n}.jsonl"
+ keys_path = root / "dev" / f"es_slice_{n}.json"
+ keys: list[str] = json.loads(keys_path.read_text(encoding="utf-8"))
+ vals: list[str] = []
+ for line_no, ln in enumerate(jsonl_path.read_text(encoding="utf-8").splitlines(), 1):
+ ln = ln.strip()
+ if not ln:
+ continue
+ try:
+ vals.append(json.loads(ln))
+ except json.JSONDecodeError as e:
+ msg = f"{jsonl_path}:{line_no}: {e}"
+ raise SystemExit(msg) from e
+ if len(vals) != len(keys):
+ msg = f"es_val_{n}.jsonl has {len(vals)} entries but es_slice_{n}.json has {len(keys)} keys"
+ raise SystemExit(msg)
+ out = here / f"es_val_{n}.json"
+ out.write_text(json.dumps(vals, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
+ print(f"Wrote {out}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/ccbt/i18n/locale_data/es_gap_all.json b/ccbt/i18n/locale_data/es_gap_all.json
new file mode 100644
index 00000000..7b7e254a
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_gap_all.json
@@ -0,0 +1,1307 @@
+{
+ "Error adding tracker: {error}": "Error al añadir tracker: {error}",
+ "Error banning peer: {error}": "Error al vetar par: {error}",
+ "Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Error al comprobar accesibilidad del demonio (intento %d/%d, transcurrido %.1fs): %s, reintentando en %.1fs...",
+ "Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s": "Error al comprobar accesibilidad del demonio tras %d intentos (transcurrido %.1fs): %s",
+ "Error checking daemon stage: %s": "Error al comprobar la fase del demonio: %s",
+ "Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection": "Error al comprobar si el demonio está en ejecución (¿problema específico de Windows?): %s - existe archivo PID, se intentará conexión IPC",
+ "Error checking if restart is needed: %s": "Error al comprobar si es necesario reiniciar: %s",
+ "Error closing HTTP session: %s": "Error al cerrar la sesión HTTP: %s",
+ "Error closing IPC client: %s": "Error al cerrar el cliente IPC: %s",
+ "Error closing WebSocket: %s": "Error al cerrar WebSocket: %s",
+ "Error comparing configs: {e}": "Error al comparar configuraciones: {e}",
+ "Error creating backup: {e}": "Error al crear la copia de seguridad: {e}",
+ "Error creating torrent": "Error al crear el torrent",
+ "Error deselecting files: {error}": "Error al deseleccionar archivos: {error}",
+ "Error executing config.get command: {error}": "Error al ejecutar el comando config.get: {error}",
+ "Error executing {operation} on daemon: {error}": "Error al ejecutar {operation} en el demonio: {error}",
+ "Error exporting configuration: {e}": "Error al exportar la configuración: {e}",
+ "Error forcing announce: {error}": "Error al forzar el anuncio: {error}",
+ "Error generating schema: {e}": "Error al generar el esquema: {e}",
+ "Error getting DHT stats: {error}": "Error al obtener estadísticas DHT: {error}",
+ "Error getting daemon status": "Error al obtener el estado del demonio",
+ "Error getting daemon status: %s": "Error al obtener el estado del demonio: %s",
+ "Error importing configuration: {e}": "Error al importar la configuración: {e}",
+ "Error in socket pre-check: %s": "Error en la precomprobación del socket: %s",
+ "Error listing backups: {e}": "Error al listar copias de seguridad: {e}",
+ "Error listing profiles: {e}": "Error al listar perfiles: {e}",
+ "Error listing templates: {e}": "Error al listar plantillas: {e}",
+ "Error loading DHT data: {error}": "Error al cargar datos DHT: {error}",
+ "Error loading DHT summary: {error}": "Error al cargar el resumen DHT: {error}",
+ "Error loading configuration: {error}": "Error al cargar la configuración: {error}",
+ "Error loading info: {error}": "Error al cargar la información: {error}",
+ "Error loading peer data: {error}": "Error al cargar datos de pares: {error}",
+ "Error loading section: {error}": "Error al cargar la sección: {error}",
+ "Error loading security data: {error}": "Error al cargar datos de seguridad: {error}",
+ "Error loading torrent config: {error}": "Error al cargar la configuración del torrent: {error}",
+ "Error loading torrent: {error}": "Error al cargar el torrent: {error}",
+ "Error opening folder: {error}": "Error al abrir la carpeta: {error}",
+ "Error processing file %s: %s": "Error al procesar el archivo %s: %s",
+ "Error reading PID file after retries: %s": "Error al leer el archivo PID tras reintentos: %s",
+ "Error reading PID file: %s": "Error al leer el archivo PID: %s",
+ "Error receiving WebSocket event: %s": "Error al recibir evento WebSocket: %s",
+ "Error receiving WebSocket events batch: %s": "Error al recibir lote de eventos WebSocket: %s",
+ "Error removing tracker: {error}": "Error al quitar tracker: {error}",
+ "Error restarting daemon": "Error al reiniciar el demonio",
+ "Error restoring backup: {e}": "Error al restaurar la copia de seguridad: {e}",
+ "Error routing to daemon (PID file exists): %s": "Error al enrutar al demonio (existe archivo PID): %s",
+ "Error routing to daemon (no PID file): %s - will create local session": "Error al enrutar al demonio (sin archivo PID): %s - se creará sesión local",
+ "Error saving configuration: {error}": "Error al guardar la configuración: {error}",
+ "Error selecting files: {error}": "Error al seleccionar archivos: {error}",
+ "Error sending shutdown request: %s": "Error al enviar solicitud de apagado: %s",
+ "Error setting DHT aggressive mode: {error}": "Error al establecer el modo agresivo DHT: {error}",
+ "Error setting file priority: {error}": "Error al establecer la prioridad del archivo: {error}",
+ "Error starting daemon": "Error al iniciar el demonio",
+ "Error stopping daemon": "Error al detener el demonio",
+ "Error stopping session: %s": "Error al detener la sesión: %s",
+ "Error submitting form: {error}": "Error al enviar el formulario: {error}",
+ "Error verifying files: {error}": "Error al verificar archivos: {error}",
+ "Error waiting for daemon with progress: %s": "Error al esperar al demonio con progreso: %s",
+ "Error waiting for daemon: %s": "Error al esperar al demonio: %s",
+ "Error waiting for metadata: %s": "Error al esperar metadatos: %s",
+ "Error with auto-tuning: {e}": "Error con el ajuste automático: {e}",
+ "Error with profile: {e}": "Error con el perfil: {e}",
+ "Error with template: {e}": "Error con la plantilla: {e}",
+ "Enable Xet Protocol:": "Habilitar protocolo Xet:",
+ "Enable debug mode (deprecated, use -vv)": "Habilitar modo de depuración (obsoleto, use -vv)",
+ "Enable debug verbosity (equivalent to -vv)": "Habilitar verbosidad de depuración (equivalente a -vv)",
+ "Enable direct I/O for writes when supported": "Habilitar E/S directa en escrituras cuando esté disponible",
+ "Enable fsync after batched writes": "Habilitar fsync tras escrituras por lotes",
+ "Enable io_uring on Linux if available": "Habilitar io_uring en Linux si está disponible",
+ "Enable metrics": "Habilitar métricas",
+ "Enable monitoring": "Habilitar monitorización",
+ "Enable protocol encryption": "Habilitar cifrado del protocolo",
+ "Enable sparse files": "Habilitar archivos dispersos",
+ "Enable streaming mode": "Habilitar modo de transmisión en flujo",
+ "Enable trace verbosity (equivalent to -vvv)": "Habilitar verbosidad de trazas (equivalente a -vvv)",
+ "Enable uTP Transport:": "Habilitar transporte uTP:",
+ "Enable uTP transport": "Habilitar transporte uTP",
+ "Failed to add content": "No se pudo añadir contenido",
+ "Failed to add magnet link": "No se pudo añadir el enlace magnet",
+ "Failed to add peer to allowlist": "No se pudo añadir el par a la lista permitida",
+ "Failed to add to queue": "No se pudo añadir a la cola",
+ "Failed to add torrent": "No se pudo añadir el torrent",
+ "Failed to add torrent to daemon": "No se pudo añadir el torrent al demonio",
+ "Failed to add tracker": "No se pudo añadir el tracker",
+ "Failed to add tracker: {error}": "No se pudo añadir el tracker: {error}",
+ "Failed to announce: {error}": "No se pudo anunciar (tracker): {error}",
+ "Failed to ban peer: {error}": "No se pudo vetar el par: {error}",
+ "Failed to calculate progress: %s": "No se pudo calcular el progreso: %s",
+ "Failed to cancel torrent": "No se pudo cancelar el torrent",
+ "Failed to cleanup Xet cache": "No se pudo limpiar la caché Xet",
+ "Failed to clear queue": "No se pudo vaciar la cola",
+ "Failed to collect custom metrics: %s": "No se pudo recopilar métricas personalizadas: %s",
+ "Failed to collect performance metrics: %s": "No se pudo recopilar métricas de rendimiento: %s",
+ "Failed to collect system metrics: %s": "No se pudo recopilar métricas del sistema: %s",
+ "Failed to copy info hash: {error}": "No se pudo copiar el info hash: {error}",
+ "Failed to deselect all files": "No se pudo deseleccionar todos los archivos",
+ "Failed to deselect files": "No se pudo deseleccionar archivos",
+ "Failed to deselect files: {error}": "No se pudo deseleccionar archivos: {error}",
+ "Failed to disable io_uring: %s": "No se pudo desactivar io_uring: %s",
+ "Failed to discover NAT": "No se pudo descubrir NAT",
+ "Failed to enable io_uring: %s": "No se pudo activar io_uring: %s",
+ "Failed to force start all torrents": "No se pudo forzar el inicio de todos los torrents",
+ "Failed to force start torrent": "No se pudo forzar el inicio del torrent",
+ "Failed to generate .tonic file": "No se pudo generar el archivo .tonic",
+ "Failed to generate tonic link": "No se pudo generar el enlace tonic",
+ "Failed to get NAT status": "No se pudo obtener el estado de NAT",
+ "Failed to get Xet cache info": "No se pudo obtener información de la caché Xet",
+ "Failed to get Xet stats": "No se pudo obtener estadísticas Xet",
+ "Failed to get config: {error}": "No se pudo obtener la configuración: {error}",
+ "Failed to get content": "No se pudo obtener el contenido",
+ "Failed to get metrics interval from config: %s": "No se pudo obtener el intervalo de métricas desde la config: %s",
+ "Failed to get peers": "No se pudo obtener los pares",
+ "Failed to get per-peer rate limit": "No se pudo obtener el límite de velocidad por par",
+ "Failed to get queue": "No se pudo obtener la cola",
+ "Failed to get stats": "No se pudo obtener estadísticas",
+ "Failed to get sync mode": "No se pudo obtener el modo de sincronización",
+ "Failed to get sync status": "No se pudo obtener el estado de sincronización",
+ "Failed to launch media player": "No se pudo abrir el reproductor multimedia",
+ "Failed to list aliases": "No se pudo listar alias",
+ "Failed to list allowlist": "No se pudo listar la lista permitida",
+ "Failed to list files": "No se pudo listar archivos",
+ "Failed to list scrape results": "No se pudo listar resultados de scrape",
+ "Failed to load DHT health data: {error}": "No se pudo cargar datos de salud DHT: {error}",
+ "Failed to load filter file: {file_path}": "No se pudo cargar el archivo de filtro: {file_path}",
+ "Failed to load global KPIs: {error}": "No se pudo cargar los KPI globales: {error}",
+ "Failed to load peer quality distribution: {error}": "No se pudo cargar la distribución de calidad de pares: {error}",
+ "Failed to load piece selection metrics: {error}": "No se pudo cargar métricas de selección de piezas: {error}",
+ "Failed to load swarm timeline: {error}": "No se pudo cargar la línea temporal del enjambre: {error}",
+ "Failed to map port": "No se pudo mapear el puerto",
+ "Failed to move in queue": "No se pudo mover en la cola",
+ "Failed to parse config value: %s": "No se pudo analizar el valor de configuración: %s",
+ "Failed to pause all torrents": "No se pudo pausar todos los torrents",
+ "Failed to pause torrent": "No se pudo pausar el torrent",
+ "Failed to pin content": "No se pudo fijar contenido en IPFS",
+ "Failed to refresh PEX": "No se pudo actualizar PEX",
+ "Failed to refresh checkpoint": "No se pudo actualizar el punto de control",
+ "Failed to refresh mappings": "No se pudo actualizar asignaciones",
+ "Failed to refresh media state: {error}": "No se pudo actualizar el estado multimedia: {error}",
+ "Failed to reload checkpoint": "No se pudo recargar el punto de control",
+ "Failed to remove alias": "No se pudo eliminar el alias",
+ "Failed to remove from queue": "No se pudo quitar de la cola",
+ "Failed to remove peer from allowlist": "No se pudo quitar el par de la lista permitida",
+ "Failed to remove tracker": "No se pudo quitar el tracker",
+ "Failed to remove tracker: {error}": "No se pudo quitar el tracker: {error}",
+ "Failed to resume all torrents": "No se pudo reanudar todos los torrents",
+ "Failed to resume torrent": "No se pudo reanudar el torrent",
+ "Failed to save config: {error}": "No se pudo guardar la configuración: {error}",
+ "Failed to save configuration to file: %s": "No se pudo guardar la configuración en archivo: %s",
+ "Failed to scrape torrent": "No se pudo hacer scrape del torrent",
+ "Failed to select all files": "No se pudo seleccionar todos los archivos",
+ "Failed to select files": "No se pudo seleccionar archivos",
+ "Failed to select files: {error}": "No se pudo seleccionar archivos: {error}",
+ "Failed to set DHT aggressive mode": "No se pudo establecer el modo agresivo DHT",
+ "Failed to set DHT aggressive mode: {error}": "No se pudo establecer el modo agresivo DHT: {error}",
+ "Failed to set alias": "No se pudo establecer el alias",
+ "Failed to set all peers rate limits": "No se pudo establecer límites de velocidad para todos los pares",
+ "Failed to set file priority": "No se pudo establecer la prioridad del archivo",
+ "Failed to set first piece priority: %s": "No se pudo establecer la prioridad de la primera pieza: %s",
+ "Failed to set last piece priority: %s": "No se pudo establecer la prioridad de la última pieza: %s",
+ "Failed to set per-peer rate limit": "No se pudo establecer el límite por par",
+ "Failed to set priority": "No se pudo establecer la prioridad",
+ "Failed to set priority: {error}": "No se pudo establecer la prioridad: {error}",
+ "Failed to set sync mode": "No se pudo establecer el modo de sincronización",
+ "Failed to share folder": "No se pudo compartir la carpeta",
+ "Failed to sign WebSocket request: %s": "No se pudo firmar la solicitud WebSocket: %s",
+ "Failed to sign request with Ed25519: %s": "No se pudo firmar la solicitud con Ed25519: %s",
+ "Failed to start media stream": "No se pudo iniciar la transmisión multimedia",
+ "Failed to start sync": "No se pudo iniciar la sincronización",
+ "Failed to stop daemon": "No se pudo detener el demonio",
+ "Failed to stop media stream": "No se pudo detener la transmisión multimedia",
+ "Failed to unmap port": "No se pudo desmapear el puerto",
+ "Failed to unpin content": "No se pudo desfijar el contenido",
+ "Enabled (Dependency Missing)": "Habilitado (falta dependencia)",
+ "Enabled (Not Started)": "Habilitado (no iniciado)",
+ "Encrypt backup with generated key": "Cifrar la copia de seguridad con clave generada",
+ "Encrypting backup...": "Cifrando copia de seguridad…",
+ "Endgame duplicate requests": "Solicitudes duplicadas en fase final",
+ "Endgame threshold (0..1)": "Umbral de fase final (0..1)",
+ "Enter Tracker URL": "Introduzca la URL del tracker",
+ "Enter path...": "Introduzca la ruta…",
+ "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.": "Introduzca el directorio donde deben descargarse los archivos:\n\nDéjelo vacío para usar el directorio actual.",
+ "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...": "Introduzca la ruta de un archivo .torrent o un enlace magnet:\n\nEjemplos:\n /ruta/al/archivo.torrent\n magnet:?xt=urn:btih:...",
+ "Enter torrent file path or magnet link": "Introduzca la ruta del archivo torrent o el enlace magnet",
+ "Enter torrent file path or magnet link:": "Introduzca la ruta del archivo torrent o el enlace magnet:",
+ "Error": "Error",
+ "Error: {error}": "Error: {error}",
+ "Errors": "Errores",
+ "Estimated Read Speed": "Velocidad de lectura estimada",
+ "Estimated Write Speed": "Velocidad de escritura estimada",
+ "Events": "Eventos",
+ "Eviction rate: {rate:.2f} /sec": "Tasa de expulsión: {rate:.2f} /s",
+ "Exceeded maximum wait time (%.1fs) for daemon readiness": "Se superó el tiempo máximo de espera (%.1fs) para la disponibilidad del demonio",
+ "Excellent": "Excelente",
+ "Exists": "Existe",
+ "Expected info hash (hex)": "Hash de información esperado (hexadecimal)",
+ "Expected type: {type_name}": "Tipo esperado: {type_name}",
+ "Export complete": "Exportación completada",
+ "Exporting checkpoint...": "Exportando punto de control…",
+ "Failed Requests": "Solicitudes fallidas",
+ "Fair": "Regular",
+ "Fetching Metadata...": "Obteniendo metadatos…",
+ "Fetching file list for selection. This may take a moment.": "Obteniendo la lista de archivos para la selección. Puede tardar un momento.",
+ "Field": "Campo",
+ "File Browser": "Navegador de archivos",
+ "File Browser - Data provider or executor not available": "Explorador de archivos: proveedor de datos o ejecutor no disponible",
+ "File Browser - Error: {error}": "Explorador de archivos: error: {error}",
+ "File Browser - Select files to create torrents": "Explorador de archivos: seleccione archivos para crear torrents",
+ "File Explorer": "Explorador de archivos",
+ "File must have .torrent extension: %s": "El archivo debe tener extensión .torrent: %s",
+ "File not found: %s": "Archivo no encontrado: %s",
+ "File {number}": "Archivo {number}",
+ "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}": "Archivo: {name}\nPuerto: {port}\nBytes servidos: {bytes_served}\nClientes: {clients}\nÚltimo rango: {start} - {end}\nBytes legibles: {available}\nÚltimo error: {error}",
+ "Files in torrent {hash}...": "Archivos en el torrent {hash}…",
+ "Files: {count}": "Archivos: {count}",
+ "Filter update failed": "Error al actualizar el filtro",
+ "Folder not found: {folder}": "Carpeta no encontrada: {folder}",
+ "Folder: {name}": "Carpeta: {name}",
+ "Force Announce": "Forzar anuncio",
+ "Force kill without graceful shutdown": "Forzar cierre sin apagado ordenado",
+ "Found {count} potential issues": "Se encontraron {count} posibles problemas",
+ "Full Path": "Ruta completa",
+ "Full configuration editing requires navigating to the Global Config screen": "La edición completa de la configuración requiere ir a la pantalla de configuración global",
+ "General": "General",
+ "General configuration - Data provider/Executor not available": "Configuración general: proveedor de datos o ejecutor no disponible",
+ "Generate new API key": "Generar nueva clave de API",
+ "Generated new API key for daemon": "Nueva clave de API generada para el demonio",
+ "Generating {format} torrent...": "Generando torrent {format}…",
+ "GitHub Dark": "GitHub oscuro",
+ "Global": "Global",
+ "Global Configuration": "Configuración global",
+ "Global Connected Peers": "Pares conectados (global)",
+ "Global KPIs": "KPI globales",
+ "Global KPIs data is unavailable in the current mode.": "Los datos de KPI globales no están disponibles en este modo.",
+ "Global Key Performance Indicators": "Indicadores clave de rendimiento globales",
+ "Global Torrent Metrics": "Métricas globales de torrent",
+ "Global config": "Configuración global",
+ "Global download limit (KiB/s)": "Límite global de descarga (KiB/s)",
+ "Global upload limit (KiB/s)": "Límite global de subida (KiB/s)",
+ "Good": "Bueno",
+ "Graceful shutdown timeout, forcing stop": "Tiempo de apagado agotado; forzando detención",
+ "Graphs": "Gráficos",
+ "Gruvbox": "Gruvbox",
+ "HTTP error checking daemon status at %s: %s (status %d)": "Error HTTP al comprobar el estado del demonio en %s: %s (estado %d)",
+ "Hash Chunk Size": "Tamaño de trozo para hash",
+ "Hash verification workers": "Hilos de verificación de trozos",
+ "Health": "Salud",
+ "Help screen": "Pantalla de ayuda",
+ "High": "Alto",
+ "Historical trends": "Tendencias históricas",
+ "Host for web interface": "Host para la interfaz web",
+ "IP Address": "Dirección IP",
+ "IP filter not available": "Filtro IP no disponible",
+ "IP:Port": "IP:Puerto",
+ "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)": "IPCClient.get_daemon_pid: comprobando pid_file=%s (home_dir=%s, existe=%s)",
+ "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.": "Opciones del protocolo IPFS:\n\nIPFS permite almacenamiento direccionado por contenido y uso compartido entre pares.\nTras la descarga se puede acceder al contenido mediante el CID de IPFS.",
+ "IPFS management": "Gestión de IPFS",
+ "Idle": "Ocioso",
+ "Inactive": "Inactivo",
+ "Include effective runtime value from loaded config (file + env)": "Incluir el valor en tiempo de ejecución efectivo de la configuración cargada (archivo + entorno)",
+ "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)": "Aumentar verbosidad (-v: detallado, -vv: depuración, -vvv: trazas)",
+ "Index": "Índice",
+ "Info": "Información",
+ "Info Hashes": "Hashes de información",
+ "Info hash copied to clipboard": "Hash de información copiado al portapapeles",
+ "Info hash: {hash}": "Hash de información: {hash}",
+ "Initial Rate": "Tasa inicial",
+ "Initial send rate": "Tasa de envío inicial",
+ "Invalid IP address: {error}": "Dirección IP no válida: {error}",
+ "Invalid IP range: {ip_range}": "Rango IP no válido: {ip_range}",
+ "Invalid configuration after merge: {e}": "Configuración no válida tras la fusión: {e}",
+ "Invalid configuration: top-level must be an object": "Configuración no válida: el nivel superior debe ser un objeto",
+ "Invalid configuration: {e}": "Configuración no válida: {e}",
+ "Invalid info hash format": "Formato de hash de información no válido",
+ "Invalid info hash format: %s": "Formato de hash de información no válido: %s",
+ "Invalid info hash format: {hash}": "Formato de hash de información no válido: {hash}",
+ "Invalid info hash length in magnet link": "Longitud de hash no válida en el enlace magnet",
+ "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu": "Configuración regional '{current_locale}' no válida. Se usará 'en'. Disponibles: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu",
+ "Invalid magnet link - missing 'xt=urn:btih:' parameter": "Enlace magnet no válido: falta el parámetro 'xt=urn:btih:'",
+ "Invalid magnet link format": "Formato de enlace magnet no válido",
+ "Invalid magnet link format - must start with 'magnet:?'": "Formato de enlace magnet no válido: debe comenzar por 'magnet:?'",
+ "Invalid peer selection": "Selección de par no válida",
+ "Invalid profile '{name}': {errors}": "Perfil '{name}' no válido: {errors}",
+ "Invalid template '{name}': {errors}": "Plantilla '{name}' no válida: {errors}",
+ "Invalid tracker URL format. Must start with http://, https://, or udp://": "Formato de URL de tracker no válido. Debe comenzar por http://, https:// o udp://",
+ "Invalid tracker selection": "Selección de tracker no válida",
+ "Key Bindings": "Atajos de teclado",
+ "Language": "Idioma",
+ "Last Error": "Último error",
+ "Last Update": "Última actualización",
+ "Last sample {age}": "Última muestra {age}",
+ "Latency": "Latencia",
+ "Light": "Claro",
+ "Light Mode": "Modo claro",
+ "List available locales": "Listar configuraciones regionales disponibles",
+ "Listen interface": "Interfaz de escucha",
+ "Listen port": "Puerto de escucha",
+ "Loading configuration...": "Cargando configuración…",
+ "Loading file list…": "Cargando lista de archivos…",
+ "Loading peer metrics...": "Cargando métricas de pares…",
+ "Loading piece selection metrics...": "Cargando métricas de selección de piezas…",
+ "Loading swarm timeline...": "Cargando línea temporal del enjambre…",
+ "Loading torrent information...": "Cargando información del torrent…",
+ "Local Node Information": "Información del nodo local",
+ "Low": "Bajo",
+ "MMap cache size (MB)": "Tamaño de caché MMap (MB)",
+ "MTU": "MTU",
+ "Magnet command: PID file check - exists=%s, path=%s": "Comando magnet: comprobación de archivo PID: existe=%s, ruta=%s",
+ "Magnet link must contain 'xt=urn:btih:' parameter": "El enlace magnet debe contener el parámetro 'xt=urn:btih:'",
+ "Magnet link must start with 'magnet:?'": "El enlace magnet debe comenzar por 'magnet:?'",
+ "Max Rate": "Tasa máxima",
+ "Max Retransmits": "Retransmisiones máx.",
+ "Max Window Size": "Tamaño máx. de ventana",
+ "Maximum": "Máximo",
+ "Maximum UDP packet size": "Tamaño máx. de paquete UDP",
+ "Maximum block size (KiB)": "Tamaño máx. de bloque (KiB)",
+ "Maximum download rate for this torrent": "Tasa máx. de descarga para este torrent",
+ "Maximum global peers": "Máximo de pares globales",
+ "Maximum peers per torrent": "Máximo de pares por torrent",
+ "Maximum receive window size": "Tamaño máx. de ventana de recepción",
+ "Maximum retransmission attempts": "Intentos máx. de retransmisión",
+ "Maximum send rate": "Tasa máx. de envío",
+ "Maximum upload rate for this torrent": "Tasa máx. de subida para este torrent",
+ "Media": "Multimedia",
+ "Media Playback": "Reproducción multimedia",
+ "Media stream started.": "Transmisión multimedia iniciada.",
+ "Media stream stopped.": "Transmisión multimedia detenida.",
+ "Medium": "Medio",
+ "Memory": "Memoria",
+ "Metadata is loading. File selection will appear when available.": "Cargando metadatos. La selección de archivos aparecerá cuando esté disponible.",
+ "Metrics explorer": "Explorador de métricas",
+ "Metrics interval (s)": "Intervalo de métricas (s)",
+ "Metrics interval: {interval}s": "Intervalo de métricas: {interval}s",
+ "Metrics port": "Puerto de métricas",
+ "Migrating checkpoint format from {from_fmt} to {to_fmt}...": "Migrando formato de punto de control de {from_fmt} a {to_fmt}…",
+ "Migration complete": "Migración completada",
+ "Min Rate": "Tasa mínima",
+ "Minimum block size (KiB)": "Tamaño mín. de bloque (KiB)",
+ "Minimum send rate": "Tasa mín. de envío",
+ "Mode": "Modo",
+ "Model '{model}' not found in Config": "Modelo '{model}' no encontrado en Config",
+ "Modified": "Modificado",
+ "Monitoring": "Monitorización",
+ "Monokai": "Monokai",
+ "N/A": "N/D",
+ "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.": "Opciones de NAT traversal:\n\nEl NAT traversal (NAT-PMP/UPnP) asigna puertos en su router automáticamente.\nPermite que los pares se conecten directamente y mejora la velocidad de descarga.",
+ "NAT management": "Gestión NAT",
+ "Name: {name}": "Nombre: {name}",
+ "Navigation": "Navegación",
+ "Navigation menu": "Menú de navegación",
+ "Network Configuration": "Configuración de red",
+ "Network Optimization Recommendations": "Recomendaciones de optimización de red",
+ "Network Performance": "Rendimiento de red",
+ "Network configuration (connections, timeouts, rate limits)": "Configuración de red (conexiones, tiempos de espera, límites de velocidad)",
+ "Network configuration - Data provider/Executor not available": "Configuración de red: proveedor de datos o ejecutor no disponible",
+ "Network quality": "Calidad de red",
+ "Network quality - Error: {error}": "Calidad de red: error: {error}",
+ "Never": "Nunca",
+ "Next": "Siguiente",
+ "Next Step": "Paso siguiente",
+ "No DHT metrics per torrent yet.": "Aún no hay métricas DHT por torrent.",
+ "No PID file found, checking for daemon via _get_executor()": "No se encontró archivo PID; comprobando demonio mediante _get_executor()",
+ "No access": "Sin acceso",
+ "No active stream to stop.": "No hay transmisión activa que detener.",
+ "No availability data": "Sin datos de disponibilidad",
+ "No checkpoint found": "No se encontró punto de control",
+ "No commands available": "No hay comandos disponibles",
+ "No configuration file to backup": "No hay archivo de configuración que respaldar",
+ "No daemon PID file found - daemon is not running": "No se encontró archivo PID del demonio; el demonio no está en ejecución",
+ "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s": "No se detectó demonio (no existe el archivo PID); creando sesión local. Ruta del PID: %s",
+ "No file selected": "Ningún archivo seleccionado",
+ "No files to deselect": "No hay archivos que deseleccionar",
+ "No files to select": "No hay archivos que seleccionar",
+ "No locales directory found": "No se encontró el directorio de configuraciones regionales",
+ "No magnet URI provided": "No se proporcionó URI magnet",
+ "No magnet URI provided for add_magnet operation.": "No se proporcionó URI magnet para la operación add_magnet.",
+ "No metrics available": "No hay métricas disponibles",
+ "No peer quality data available": "No hay datos de calidad de pares",
+ "No peer selected": "Ningún par seleccionado",
+ "No peers available": "No hay pares disponibles",
+ "No per-torrent data available": "No hay datos por torrent",
+ "No pieces": "Sin piezas",
+ "No playable files": "Sin archivos reproducibles",
+ "No playable media files were detected for this torrent.": "No se detectaron archivos multimedia reproducibles para este torrent.",
+ "No recent security events.": "No hay eventos de seguridad recientes.",
+ "No section selected for editing": "Ninguna sección seleccionada para editar",
+ "No significant events detected.": "No se detectaron eventos significativos.",
+ "No swarm activity captured for the selected window.": "No se capturó actividad del enjambre en la ventana seleccionada.",
+ "No swarm samples": "Sin muestras del enjambre",
+ "No torrent data loaded. Please go back to step 1.": "No se cargaron datos del torrent. Vuelva al paso 1.",
+ "No torrent path or magnet provided": "No se proporcionó ruta de torrent ni magnet",
+ "No torrent path or magnet provided for add_torrent operation.": "No se proporcionó ruta ni magnet para la operación add_torrent.",
+ "No torrents with DHT activity yet.": "Aún no hay torrents con actividad DHT.",
+ "No torrents yet. Use 'add' to start downloading.": "Aún no hay torrents. Use 'add' para empezar a descargar.",
+ "No tracker selected": "Ningún tracker seleccionado",
+ "No trackers found": "No se encontraron trackers",
+ "Node ID": "ID de nodo",
+ "Node Information": "Información del nodo",
+ "Node information not available.": "Información del nodo no disponible.",
+ "Nodes/Q": "Nodos/cola",
+ "Non-Empty Buckets": "Cubetas no vacías",
+ "Nord": "Nord",
+ "Normal": "Normal",
+ "Not enabled": "No habilitado",
+ "Not enabled in configuration": "No habilitado en la configuración",
+ "Not initialized": "No inicializado",
+ "Note": "Nota",
+ "Number of pieces to verify for integrity (0 = disable)": "Número de piezas a verificar por integridad (0 = desactivar)",
+ "OK (dry-run — configuration is valid)": "OK (simulación — la configuración es válida)",
+ "OK (dry-run — merged configuration is valid)": "OK (simulación — la configuración fusionada es válida)",
+ "One Dark": "One Dark",
+ "Only options in this top-level section (e.g. network)": "Solo opciones en esta sección de nivel superior (p. ej. red)",
+ "Only paths starting with this prefix": "Solo rutas que comiencen con este prefijo",
+ "Open File": "Abrir archivo",
+ "Open Folder": "Abrir carpeta",
+ "Open in VLC": "Abrir en VLC",
+ "Opened folder: {path}": "Carpeta abierta: {path}",
+ "Opened stream in external player via {method}.": "Transmisión abierta en reproductor externo mediante {method}.",
+ "Optimistic unchoke interval (s)": "Intervalo de optimistic unchoke (s)",
+ "Option": "Opción",
+ "Others can join with: ccbt tonic sync \"{link}\" --output ": "Otros pueden unirse con: ccbt tonic sync \"{link}\" --output ",
+ "Output Directory": "Directorio de salida",
+ "Output directory": "Directorio de salida",
+ "Output directory (default: current directory)": "Directorio de salida (predeterminado: directorio actual)",
+ "Output directory not available": "Directorio de salida no disponible",
+ "Output file path": "Ruta del archivo de salida",
+ "Output format for the option catalog": "Formato de salida del catálogo de opciones",
+ "Overall Efficiency": "Eficiencia general",
+ "Overall Health": "Salud general",
+ "Override IPC server port": "Sobrescribir puerto del servidor IPC",
+ "PEX interval (s)": "Intervalo PEX (s)",
+ "PEX refresh failed: {error}": "Error al actualizar PEX: {error}",
+ "PEX refresh requested": "Actualización de PEX solicitada",
+ "PEX: Failed": "PEX: fallido",
+ "PID file contains invalid PID: %d, removing": "El archivo PID contiene un PID no válido: %d; eliminando",
+ "PID file contains invalid data: %r, removing": "El archivo PID contiene datos no válidos: %r; eliminando",
+ "PID file is empty, removing": "El archivo PID está vacío; eliminando",
+ "Parsing files and building file tree...": "Analizando archivos y construyendo el árbol...",
+ "Parsing files and building hybrid metadata...": "Analizando archivos y construyendo metadatos híbridos...",
+ "Patch file format (auto: infer from extension or try JSON then TOML)": "Formato de parche (auto: inferir por extensión o probar JSON y luego TOML)",
+ "Patch must be a JSON/TOML object at the top level": "El parche debe ser un objeto JSON/TOML en el nivel superior",
+ "Path": "Ruta",
+ "Path does not exist": "La ruta no existe",
+ "Path is not a file: %s": "La ruta no es un archivo: %s",
+ "Path or magnet://...": "Ruta o magnet://...",
+ "Path to config file": "Ruta del archivo de configuración",
+ "Pause failed: {error}": "Error al pausar: {error}",
+ "Pause torrent": "Pausar torrent",
+ "Paused": "En pausa",
+ "Paused {info_hash}…": "Pausado {info_hash}…",
+ "Peer": "Par",
+ "Peer Details": "Detalles del par",
+ "Peer Distribution": "Distribución de pares",
+ "Peer Efficiency": "Eficiencia de pares",
+ "Peer Quality": "Calidad del par",
+ "Peer Quality Distribution": "Distribución de calidad de pares",
+ "Peer Selection": "Selección de pares",
+ "Peer banning not yet implemented. Selected peer: {ip}:{port}": "Vetado de pares aún no implementado. Par seleccionado: {ip}:{port}",
+ "Peer distribution - Error: {error}": "Distribución de pares — error: {error}",
+ "Peer not found": "Par no encontrado",
+ "Peer quality - Error: {error}": "Calidad de pares — error: {error}",
+ "Peer quality data is unavailable in the current mode.": "Datos de calidad de pares no disponibles en este modo.",
+ "Peer timeout (s)": "Tiempo de espera del par (s)",
+ "Peer {ip}:{port} banned": "Par {ip}:{port} vetado",
+ "Peers Found": "Pares encontrados",
+ "Peers/Q": "Pares/cola",
+ "Per-Peer": "Por par",
+ "Per-Peer tab - Data provider or executor not available": "Pestaña por par: proveedor de datos o ejecutor no disponible",
+ "Per-Torrent": "Por torrent",
+ "Per-Torrent Config: {hash}...": "Config. por torrent: {hash}...",
+ "Per-Torrent Configuration": "Configuración por torrent",
+ "Per-Torrent Configuration: {name}": "Configuración por torrent: {name}",
+ "Per-Torrent Quality Summary": "Resumen de calidad por torrent",
+ "Per-Torrent tab - Data provider or executor not available": "Pestaña por torrent: proveedor de datos o ejecutor no disponible",
+ "Per-torrent DHT": "DHT por torrent",
+ "Per-torrent configuration - Data provider/Executor or torrent not available": "Configuración por torrent: proveedor de datos, ejecutor o torrent no disponible",
+ "Per-torrent configuration saved successfully": "Configuración por torrent guardada correctamente",
+ "Percentage": "Porcentaje",
+ "Performance metrics": "Métricas de rendimiento",
+ "Performance metrics - Error: {error}": "Métricas de rendimiento — error: {error}",
+ "Permission denied": "Permiso denegado",
+ "Piece Selection Strategy": "Estrategia de selección de piezas",
+ "Piece selection metrics are not available yet for this torrent.": "Las métricas de selección de piezas aún no están disponibles para este torrent.",
+ "Piece selection metrics are unavailable in the current mode.": "Métricas de selección de piezas no disponibles en este modo.",
+ "Pieces Received": "Piezas recibidas",
+ "Pieces Served": "Piezas servidas",
+ "Pin Content in IPFS:": "Fijar contenido en IPFS:",
+ "Pipeline Rejections": "Rechazos de la canalización",
+ "Pipeline Utilization": "Utilización de la canalización",
+ "Please enter a torrent path or magnet link": "Introduzca la ruta del torrent o el enlace magnet",
+ "Please fix parse errors before saving": "Corrija errores de análisis antes de guardar",
+ "Please fix validation errors before saving": "Corrija errores de validación antes de guardar",
+ "Please select a torrent first": "Seleccione primero un torrent",
+ "Poor": "Deficiente",
+ "Port for web interface": "Puerto para la interfaz web",
+ "Port: {port}, STUN: {stun_count} server(s)": "Puerto: {port}, STUN: {stun_count} servidor(es)",
+ "Prefer Protocol v2 when available": "Preferir protocolo v2 cuando esté disponible",
+ "Prefer over TCP": "Preferir sobre TCP",
+ "Prefer uTP when both TCP and uTP are available": "Preferir uTP cuando TCP y uTP estén disponibles",
+ "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s": "Preferir v2: {prefer_v2} | Híbrido: {hybrid} | Tiempo de espera: {timeout}s",
+ "Press Ctrl+C to stop the daemon": "Pulse Ctrl+C para detener el demonio",
+ "Press Enter to configure this section": "Pulse Intro para configurar esta sección",
+ "Previous": "Anterior",
+ "Previous Step": "Paso anterior",
+ "Prioritize first piece": "Priorizar la primera pieza",
+ "Prioritize last piece": "Priorizar la última pieza",
+ "Prioritized Pieces": "Piezas priorizadas",
+ "Priority (0 = normal, 1 = high, -1 = low):": "Prioridad (0 = normal, 1 = alta, -1 = baja):",
+ "Priority level": "Nivel de prioridad",
+ "Profile '{name}' not found": "Perfil '{name}' no encontrado",
+ "Profile applied to {path}": "Perfil aplicado en {path}",
+ "Profile config written to {path}": "Configuración de perfil escrita en {path}",
+ "Profile: {name}": "Perfil: {name}",
+ "Protocol v2 (BEP 52)": "Protocolo v2 (BEP 52)",
+ "Protocols (Ctrl+)": "Protocolos (Ctrl+)",
+ "Provide a VALUE argument or use --value=... for values with spaces or JSON": "Proporcione un argumento VALUE o use --value=... para valores con espacios o JSON",
+ "Proxy config": "Configuración del proxy",
+ "Public key must be 32 bytes (64 hex characters)": "La clave pública debe tener 32 bytes (64 caracteres hexadecimales)",
+ "PyYAML is required for YAML export": "PyYAML es necesario para exportar YAML",
+ "PyYAML is required for YAML import": "PyYAML es necesario para importar YAML",
+ "PyYAML is required for YAML patches": "PyYAML es necesario para parches YAML",
+ "Quality": "Calidad",
+ "Quality Distribution": "Distribución de calidad",
+ "Queries": "Consultas",
+ "Queries Received": "Consultas recibidas",
+ "Queries Sent": "Consultas enviadas",
+ "Quick Add Torrent": "Añadir torrent rápido",
+ "Quick Stats": "Estadísticas rápidas",
+ "Quick add torrent": "Añadir torrent rápido",
+ "RTT multiplier for retransmit timeout": "Multiplicador RTT para tiempo de espera de retransmisión",
+ "Rainbow": "Rainbow",
+ "Rate Limits (KiB/s)": "Límites de velocidad (KiB/s)",
+ "Rate limit configuration (global and per-torrent)": "Configuración de límites de velocidad (global y por torrent)",
+ "Rates": "Velocidades",
+ "Read IPC port %d from daemon config file (authoritative source)": "Leer puerto IPC %d del archivo de configuración del demonio (fuente autoritativa)",
+ "Recent Security Events ({count})": "Eventos de seguridad recientes ({count})",
+ "Recommended Settings": "Ajustes recomendados",
+ "Recommended Value": "Valor recomendado",
+ "Reconnect to peers from checkpoint": "Reconectar a pares desde el punto de control",
+ "Recovery & Pipeline Health": "Recuperación y salud de la canalización",
+ "Refresh": "Actualizar",
+ "Refresh PEX": "Actualizar PEX",
+ "Refresh tracker state from checkpoint": "Actualizar estado del tracker desde el punto de control",
+ "Rehash: Failed": "Rehash: fallido",
+ "Remaining chunks: {count}": "Fragmentos restantes: {count}",
+ "Remove": "Quitar",
+ "Remove Tracker": "Quitar tracker",
+ "Remove checkpoints older than N days": "Eliminar puntos de control más antiguos que N días",
+ "Remove failed: {error}": "Error al quitar: {error}",
+ "Remove tracker not yet implemented. Selected tracker: {url}": "Quitar tracker aún no implementado. Tracker seleccionado: {url}",
+ "Reputation Tracking": "Seguimiento de reputación",
+ "Request Efficiency": "Eficiencia de solicitudes",
+ "Request Latency": "Latencia de solicitud",
+ "Request Success": "Éxito de solicitudes",
+ "Request pipeline depth": "Profundidad de canalización de solicitudes",
+ "Required": "Obligatorio",
+ "Reset specific key only (otherwise resets all options)": "Restablecer solo una clave concreta (si no, restablece todas las opciones)",
+ "Resource": "Recurso",
+ "Resource Utilization": "Utilización de recursos",
+ "Responses Received": "Respuestas recibidas",
+ "Restart Required": "Reinicio necesario",
+ "Restart daemon now?": "¿Reiniciar el demonio ahora?",
+ "Restore complete": "Restauración completada",
+ "Restore failed": "Restauración fallida",
+ "Restoring checkpoint...": "Restaurando punto de control...",
+ "Resume failed: {error}": "Error al reanudar: {error}",
+ "Resume from checkpoint if available": "Reanudar desde el punto de control si está disponible",
+ "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.": "Reanudar desde el punto de control si está disponible:\n\nSi está habilitado, la descarga continuará desde el último punto de control.",
+ "Resume from checkpoint:": "Reanudar desde el punto de control:",
+ "Resume from checkpoint?": "¿Reanudar desde el punto de control?",
+ "Resume torrent": "Reanudar torrent",
+ "Resumed {info_hash}…": "Reanudado {info_hash}…",
+ "Resuming {name}": "Reanudando {name}",
+ "Retransmit Timeout Factor": "Factor de tiempo de espera de retransmisión",
+ "Routing Table": "Tabla de enrutamiento",
+ "Routing table statistics not available.": "Estadísticas de tabla de enrutamiento no disponibles.",
+ "Rule not found: {ip_range}": "Regla no encontrada: {ip_range}",
+ "Run additional system compatibility checks after model validation": "Ejecutar comprobaciones adicionales de compatibilidad del sistema tras la validación del modelo",
+ "Run in foreground (for debugging)": "Ejecutar en primer plano (para depuración)",
+ "SSL config": "Configuración SSL",
+ "Save Config": "Guardar configuración",
+ "Save Configuration": "Guardar configuración",
+ "Save checkpoint after reset": "Guardar punto de control tras el restablecimiento",
+ "Save checkpoint immediately after setting option": "Guardar punto de control inmediatamente tras establecer la opción",
+ "Saving torrent to {path}...": "Guardando torrent en {path}...",
+ "Scanning folder and calculating chunks...": "Escaneando carpeta y calculando trozos...",
+ "Schema written to {path}": "Esquema escrito en {path}",
+ "Scrape": "Scrape",
+ "Scrape Count": "Recuento de scrape",
+ "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.": "Opciones de scrape:\n\nEl scrape consulta estadísticas del tracker (seeders, leechers, descargas completadas).\nEl auto-scrape consultará el tracker automáticamente al añadir el torrent.",
+ "Scrape results": "Resultados de scrape",
+ "Scrape: Failed": "Scrape: fallido",
+ "Search torrents...": "Buscar torrents...",
+ "Section": "Sección",
+ "Section '{section}' is not a configuration section": "La sección '{section}' no es una sección de configuración",
+ "Section '{section}' not found": "Sección '{section}' no encontrada",
+ "Section: {section}": "Sección: {section}",
+ "Security": "Seguridad",
+ "Security Events": "Eventos de seguridad",
+ "Security Scan Status": "Estado del análisis de seguridad",
+ "Security Statistics": "Estadísticas de seguridad",
+ "Security configuration - Data provider/Executor not available": "Configuración de seguridad: proveedor de datos o ejecutor no disponible",
+ "Security manager not available. Security scanning requires local session mode.": "Gestor de seguridad no disponible. El análisis requiere modo de sesión local.",
+ "Security scan": "Análisis de seguridad",
+ "Security scan completed. No issues detected.": "Análisis de seguridad completado. No se detectaron problemas.",
+ "Security scan completed. {blocked} blocked connections, {events} security events detected.": "Análisis de seguridad completado. {blocked} conexiones bloqueadas, {events} eventos de seguridad detectados.",
+ "Security scan is not available when connected to daemon.": "El análisis de seguridad no está disponible al estar conectado al demonio.",
+ "Security settings (encryption, IP filtering, SSL)": "Ajustes de seguridad (cifrado, filtrado IP, SSL)",
+ "Seeding": "Siendo semilla",
+ "Seeds": "Semillas",
+ "Select": "Seleccionar",
+ "Select All": "Seleccionar todo",
+ "Select File Priority": "Seleccionar prioridad de archivo",
+ "Select Files to Download": "Seleccionar archivos para descargar",
+ "Select Language": "Seleccionar idioma",
+ "Select Priority": "Seleccionar prioridad",
+ "Select Section": "Seleccionar sección",
+ "Select Theme": "Seleccionar tema",
+ "Select a graph type to view": "Seleccione un tipo de gráfico",
+ "Select a section to configure": "Seleccione una sección para configurar",
+ "Select a section to configure. Press Enter to edit, Escape to go back.": "Seleccione una sección para configurar. Intro para editar, Escape para volver.",
+ "Select a sub-tab to view configuration options": "Seleccione una subpestaña para ver opciones de configuración",
+ "Select a sub-tab to view torrents": "Seleccione una subpestaña para ver torrents",
+ "Select a torrent and sub-tab to view details": "Seleccione un torrent y una subpestaña para ver detalles",
+ "Select a torrent insight tab": "Seleccione una pestaña de información del torrent",
+ "Select a workflow tab": "Seleccione una pestaña de flujo de trabajo",
+ "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all": "Seleccione archivos para descargar y establezca prioridades:\n Espacio: Alternar selección\n P: Cambiar prioridad\n A: Seleccionar todo\n D: Deseleccionar todo",
+ "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)": "Seleccionar archivos: [a]todos, [n]inguno o índices (p. ej. 0,2-5)",
+ "Select folder": "Seleccionar carpeta",
+ "Select playable file": "Seleccionar archivo reproducible",
+ "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.": "Seleccione la prioridad en cola para este torrent:\n\nLos torrents de mayor prioridad se iniciarán primero.",
+ "Select torrent...": "Seleccionar torrent...",
+ "Selected {count} file(s)": "Seleccionado(s) {count} archivo(s)",
+ "Set Limits": "Establecer límites",
+ "Set Priority": "Establecer prioridad",
+ "Set locale (e.g., 'en', 'es', 'fr')": "Establecer configuración regional (p. ej. 'en', 'es', 'fr')",
+ "Set priority to {priority} for file": "Establecer prioridad {priority} para el archivo",
+ "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.": "Establecer límites de velocidad para este torrent:\n\nIntroduzca 0 o déjelo vacío para ilimitado.",
+ "Setting": "Ajuste",
+ "Share Ratio": "Ratio de compartición",
+ "Share failed": "Error al compartir",
+ "Shared Peers": "Pares compartidos",
+ "Show checkpoints in specific format": "Mostrar puntos de control en un formato concreto",
+ "Show what would be deleted without actually deleting": "Mostrar qué se eliminaría sin eliminarlo realmente",
+ "Shutdown timeout in seconds": "Tiempo de espera de apagado en segundos",
+ "Size: {size}": "Tamaño: {size}",
+ "Skip & Continue": "Omitir y continuar",
+ "Skip waiting and select all files": "Omitir espera y seleccionar todos los archivos",
+ "Socket Optimizations": "Optimizaciones de socket",
+ "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.": "Prueba de conexión de socket a %s:%d fallida (resultado=%d). El puerto puede estar cerrado o un firewall lo bloquea. Se continuará con la comprobación HTTP de todas formas.",
+ "Socket manager not initialized": "Gestor de sockets no inicializado",
+ "Socket receive buffer (KiB)": "Búfer de recepción del socket (KiB)",
+ "Socket send buffer (KiB)": "Búfer de envío del socket (KiB)",
+ "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.": "La prueba de socket devolvió 10035 (WSAEWOULDBLOCK) en Windows para %s:%d. Puede ser un falso positivo; se continúa con la comprobación HTTP.",
+ "Solarized Dark": "Solarized oscuro",
+ "Solarized Light": "Solarized claro",
+ "Source path does not exist: %s": "La ruta de origen no existe: %s",
+ "Speed Category": "Categoría de velocidad",
+ "Speeds": "Velocidades",
+ "Start Stream": "Iniciar transmisión",
+ "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.": "Inicie una transmisión para exponer una URL HTTP en localhost para VLC u otro reproductor externo. El vídeo integrado en terminal no está contemplado.",
+ "Start daemon in background without waiting for completion (faster startup)": "Iniciar demonio en segundo plano sin esperar a que termine (inicio más rápido)",
+ "Start interactive mode": "Modo interactivo",
+ "Start the stream before opening VLC.": "Inicie la transmisión antes de abrir VLC.",
+ "Starting daemon...": "Iniciando demonio...",
+ "Starting file verification...": "Iniciando verificación de archivos...",
+ "State: stopped\nSelected file index: {index}": "Estado: detenido\nÍndice de archivo seleccionado: {index}",
+ "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}": "Estado: {state}\nURL: {url}\nPreparación del búfer: {buffer:.0%}",
+ "Step {current}/{total}: {steps}": "Paso {current}/{total}: {steps}",
+ "Stop Stream": "Detener transmisión",
+ "Stopped": "Detenido",
+ "Stopping daemon for restart...": "Deteniendo demonio para reiniciar...",
+ "Stopping daemon...": "Deteniendo demonio...",
+ "Stopping daemon... ({elapsed:.1f}s)": "Deteniendo demonio... ({elapsed:.1f}s)",
+ "Storage": "Almacenamiento",
+ "Storage Device Detection": "Detección de dispositivo de almacenamiento",
+ "Storage Type": "Tipo de almacenamiento",
+ "Storage configuration - Data provider/Executor not available": "Configuración de almacenamiento: proveedor de datos o ejecutor no disponible",
+ "Strategy": "Estrategia",
+ "Stuck Pieces Recovered": "Piezas atascadas recuperadas",
+ "Submit": "Enviar",
+ "Success": "Correcto",
+ "Successful Requests": "Solicitudes correctas",
+ "Summary": "Resumen",
+ "Supported MVP playback targets include common audio/video files.": "Los destinos de reproducción MVP admitidos incluyen archivos de audio/vídeo habituales.",
+ "Swarm Health": "Salud del enjambre",
+ "Swarm Timeline": "Línea temporal del enjambre",
+ "Swarm health - Error: {error}": "Salud del enjambre — error: {error}",
+ "Swarm timeline - Error: {error}": "Línea temporal del enjambre — error: {error}",
+ "System Efficiency": "Eficiencia del sistema",
+ "System recommendations:": "Recomendaciones del sistema:",
+ "System resources": "Recursos del sistema",
+ "System resources - Error: {error}": "Recursos del sistema — error: {error}",
+ "Template '{name}' not found": "Plantilla '{name}' no encontrada",
+ "Template applied to {path}": "Plantilla aplicada en {path}",
+ "Template config written to {path}": "Configuración de plantilla escrita en {path}",
+ "Template: {name}": "Plantilla: {name}",
+ "Templates: {templates}": "Plantillas: {templates}",
+ "Textual Dark": "Textual oscuro",
+ "Theme": "Tema",
+ "Theme: {theme}": "Tema: {theme}",
+ "This torrent has no files to select.": "Este torrent no tiene archivos para seleccionar.",
+ "This will modify your configuration file. Continue?": "Esto modificará su archivo de configuración. ¿Continuar?",
+ "Tier": "Nivel",
+ "Time": "Tiempo",
+ "Timeline": "Línea temporal",
+ "Timeline data is unavailable in the current mode.": "Datos de línea temporal no disponibles en este modo.",
+ "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Tiempo de espera al comprobar accesibilidad del demonio (intento %d/%d, transcurrido %.1fs), reintentando en %.1fs...",
+ "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)": "Tiempo de espera al comprobar accesibilidad del demonio tras %d intentos (transcurrido %.1fs)",
+ "Timeout checking daemon status at %s (daemon may be starting up or overloaded)": "Tiempo de espera al comprobar el estado del demonio en %s (el demonio puede estar iniciándose o sobrecargado)",
+ "Tip: full option catalog and file merge → ": "Sugerencia: catálogo completo de opciones y fusión de archivos → ",
+ "Toggle Dark/Light": "Alternar oscuro/claro",
+ "Tokyo Night": "Tokyo Night",
+ "Top 10 Peers by Quality": "Los 10 mejores pares por calidad",
+ "Top profile entries:": "Entradas principales del perfil:",
+ "Torrent": "Torrent",
+ "Torrent Control": "Control de torrent",
+ "Torrent Controls": "Controles de torrent",
+ "Torrent Controls - Data provider or executor not available": "Controles de torrent: proveedor de datos o ejecutor no disponible",
+ "Torrent Controls - Error: {error}": "Controles de torrent — error: {error}",
+ "Torrent File Explorer": "Explorador de archivos de torrent",
+ "Torrent Information": "Información del torrent",
+ "Torrent config": "Configuración del torrent",
+ "Torrent file is empty: %s": "El archivo torrent está vacío: %s",
+ "Torrent file not found: %s": "Archivo torrent no encontrado: %s",
+ "Torrent paused": "Torrent en pausa",
+ "Torrent priority": "Prioridad del torrent",
+ "Torrent removed": "Torrent eliminado",
+ "Torrent resumed": "Torrent reanudado",
+ "Torrent saved to {path}": "Torrent guardado en {path}",
+ "Torrents tab - Data provider or executor not available": "Pestaña Torrents: proveedor de datos o ejecutor no disponible",
+ "Torrents with DHT": "Torrents con DHT",
+ "Total Buckets": "Cubetas totales",
+ "Total Connections": "Conexiones totales",
+ "Total Downloaded": "Descargado total",
+ "Total Nodes": "Nodos totales",
+ "Total Peers": "Pares totales",
+ "Total Peers: {total} | Active Peers: {active}": "Pares totales: {total} | Pares activos: {active}",
+ "Total Queries": "Consultas totales",
+ "Total Requests": "Solicitudes totales",
+ "Total Size": "Tamaño total",
+ "Total Uploaded": "Subida total",
+ "Total chunks: {count}": "Trozos totales: {count}",
+ "Total queries": "Consultas totales",
+ "Tracker": "Tracker",
+ "Tracker Error": "Error de tracker",
+ "Tracker added: {url}": "Tracker añadido: {url}",
+ "Tracker announce interval (s)": "Intervalo de announce del tracker (s)",
+ "Tracker removed: {url}": "Tracker quitado: {url}",
+ "Tracker scrape interval (s)": "Intervalo de scrape del tracker (s)",
+ "Trackers": "Trackers",
+ "Tracking {count} torrent(s) across {minutes} minute window": "Siguiendo {count} torrent(s) en una ventana de {minutes} minuto(s)",
+ "Trend: {trend} ({delta:+.1f}pp)": "Tendencia: {trend} ({delta:+.1f}pp)",
+ "UI refresh interval: {interval}s": "Intervalo de actualización de la UI: {interval}s",
+ "URL": "URL",
+ "Unavailable": "No disponible",
+ "Unchoke interval (s)": "Intervalo de unchoke (s)",
+ "Unexpected error checking daemon status at %s: %s": "Error inesperado al comprobar el estado del demonio en %s: %s",
+ "Unknown error": "Error desconocido",
+ "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.": "Operación desconocida «{operation}» solicitada pero existe archivo PID del demonio. No debería ocurrir; repórtelo como error.",
+ "Unknown operation: %s": "Operación desconocida: %s",
+ "Unlimited": "Ilimitado",
+ "Up (B/s)": "Subida (B/s)",
+ "Updated at {time}": "Actualizado a las {time}",
+ "Updated config file with daemon configuration": "Archivo de configuración actualizado con la del demonio",
+ "Upload Limit": "Límite de subida",
+ "Upload Limit (KiB/s):": "Límite de subida (KiB/s):",
+ "Upload Rate": "Tasa de subida",
+ "Upload Rate Limit (bytes/sec, 0 = unlimited):": "Límite de tasa de subida (bytes/s, 0 = ilimitado):",
+ "Upload limit (KiB/s, 0 = unlimited)": "Límite de subida (KiB/s, 0 = ilimitado)",
+ "Upload:": "Subida:",
+ "Uploaded": "Subido",
+ "Uploading": "Subiendo",
+ "Uptime": "Tiempo en marcha",
+ "Usage": "Uso",
+ "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema": "Uso: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema",
+ "Usage: disk [show|stats|config |monitor]": "Uso: disk [show|stats|config |monitor]",
+ "Usage: network [show|stats|config |optimize|monitor]": "Uso: network [show|stats|config |optimize|monitor]",
+ "Use 'btbt daemon restart' or restart the daemon manually.": "Use «btbt daemon restart» o reinicie el demonio manualmente.",
+ "Use --confirm to proceed with restore": "Use --confirm para continuar con la restauración",
+ "Use --force to force kill": "Use --force para forzar la terminación",
+ "Use Protocol v2 only (disable v1)": "Usar solo protocolo v2 (desactivar v1)",
+ "Use memory mapping": "Usar asignación en memoria (mmap)",
+ "Using IPC port %d from main config": "Usando puerto IPC %d de la configuración principal",
+ "Using daemon config file: port=%d, api_key_present=%s": "Usando archivo de configuración del demonio: puerto=%d, api_key_present=%s",
+ "Using daemon executor for magnet command": "Usando ejecutor del demonio para el comando magnet",
+ "Using default IPC port %d (daemon config file may not exist)": "Usando puerto IPC predeterminado %d (puede no existir el archivo de config. del demonio)",
+ "Utilization Median": "Mediana de utilización",
+ "Utilization Range": "Rango de utilización",
+ "Utilization Samples": "Muestras de utilización",
+ "V1 torrent generation not yet implemented": "Generación de torrent v1 aún no implementada",
+ "VS Code Dark": "VS Code oscuro",
+ "Validate merged file overlay only; do not write": "Validar solo la superposición del archivo fusionado; no escribir",
+ "Validate only; do not write the config file": "Solo validar; no escribir el archivo de configuración",
+ "Validation error: %s": "Error de validación: %s",
+ "Value to set (use for strings with spaces or JSON); overrides positional VALUE": "Valor a establecer (útil para cadenas con espacios o JSON); sobrescribe VALUE posicional",
+ "Verification complete: {verified} verified, {failed} failed out of {total}": "Verificación completada: {verified} correctos, {failed} fallidos de {total}",
+ "Verification failed: {error}": "Verificación fallida: {error}",
+ "Verify Files": "Verificar archivos",
+ "Visual": "Visual",
+ "Wait for Metadata": "Esperar metadatos",
+ "Wait for metadata and prompt for file selection (interactive only)": "Esperar metadatos y solicitar selección de archivos (solo interactivo)",
+ "Warnings:": "Advertencias:",
+ "WebSocket error in batch receive: %s": "Error WebSocket en recepción por lotes: %s",
+ "WebSocket error: %s": "Error WebSocket: %s",
+ "WebSocket receive loop error: %s": "Error en bucle de recepción WebSocket: %s",
+ "WebTorrent": "WebTorrent",
+ "Whitelist Size": "Tamaño de lista blanca",
+ "Whitelisted Peers": "Pares en lista blanca",
+ "Windows-specific error checking daemon (os.kill() issue): %s - no PID file found, will create local session": "Error específico de Windows al comprobar el demonio (os.kill()): %s — no hay archivo PID; se creará sesión local",
+ "Write Batch Timeout": "Tiempo de espera de lote de escritura",
+ "Write batch size (KiB)": "Tamaño de lote de escritura (KiB)",
+ "Write buffer size (KiB)": "Tamaño de búfer de escritura (KiB)",
+ "Write merged config to global config file": "Escribir configuración fusionada en el archivo global",
+ "Write merged config to project local ccbt.toml": "Escribir configuración fusionada en ccbt.toml local del proyecto",
+ "Write-Back Cache": "Caché de write-back",
+ "Writing export file...": "Escribiendo archivo de exportación...",
+ "Wrote catalog to {path}": "Catálogo escrito en {path}",
+ "XET Folders": "Carpetas XET",
+ "Xet Protocol Options:\n\nXet enables content-defined chunking and deduplication.\nUseful for reducing storage when downloading similar content.": "Opciones del protocolo Xet:\n\nXet permite trozos definidos por contenido y deduplicación.\nÚtil para reducir almacenamiento al descargar contenido similar.",
+ "Xet management": "Gestión Xet",
+ "You can skip waiting and continue with all files selected.": "Puede omitir la espera y continuar con todos los archivos seleccionados.",
+ "Zero-state count": "Recuento de estado cero",
+ "[blue]Progress: {verified}/{total} pieces verified[/blue]": "[blue]Progreso: {verified}/{total} piezas verificadas[/blue]",
+ "[blue]Running: {command}[/blue]": "[blue]Ejecutando: {command}[/blue]",
+ "[bold green]Share link:[/bold green]": "[bold green]Enlace para compartir:[/bold green]",
+ "[bold]Aliases ({count}):[/bold]\n": "[bold]Alias ({count}):[/bold]\n",
+ "[bold]Allowlist ({count} peers):[/bold]\n": "[bold]Lista permitida ({count} pares):[/bold]\n",
+ "[bold]Configuration:[/bold]": "[bold]Configuración:[/bold]",
+ "[bold]Discovering NAT devices...[/bold]\n": "[bold]Descubriendo dispositivos NAT...[/bold]\n",
+ "[bold]Mapping {protocol} port {port}...[/bold]": "[bold]Asignando puerto {protocol} {port}...[/bold]",
+ "[bold]NAT Traversal Status[/bold]\n": "[bold]Estado de NAT traversal[/bold]\n",
+ "[bold]Removing {protocol} port mapping for port {port}...[/bold]": "[bold]Quitando asignación de puerto {protocol} para puerto {port}...[/bold]",
+ "[bold]Sync Mode for: {path}[/bold]\n": "[bold]Modo de sincronización para: {path}[/bold]\n",
+ "[bold]Sync Status for: {path}[/bold]\n": "[bold]Estado de sincronización para: {path}[/bold]\n",
+ "[bold]Xet Cache Information[/bold]\n": "[bold]Información de caché Xet[/bold]\n",
+ "[bold]Xet Deduplication Cache Statistics[/bold]\n": "[bold]Estadísticas de caché de deduplicación Xet[/bold]\n",
+ "[bold]Xet Protocol Status[/bold]\n": "[bold]Estado del protocolo Xet[/bold]\n",
+ "[cyan]Checking for existing daemon instance...[/cyan]": "[cyan]Comprobando si ya hay una instancia del demonio...[/cyan]",
+ "[cyan]Creating {format} torrent...[/cyan]": "[cyan]Creando torrent {format}...[/cyan]",
+ "[cyan]Download:[/cyan] {rate:.2f} KiB/s": "[cyan]Descarga:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Initializing configuration...[/cyan]": "[cyan]Inicializando configuración...[/cyan]",
+ "[cyan]Loading filter from: {file_path}[/cyan]": "[cyan]Cargando filtro desde: {file_path}[/cyan]",
+ "[cyan]Restarting daemon...[/cyan]": "[cyan]Reiniciando demonio...[/cyan]",
+ "[cyan]Running diagnostic checks...[/cyan]\n": "[cyan]Ejecutando comprobaciones de diagnóstico...[/cyan]\n",
+ "[cyan]Starting daemon in background...[/cyan]": "[cyan]Iniciando demonio en segundo plano...[/cyan]",
+ "[cyan]Starting daemon in foreground mode...[/cyan]": "[cyan]Iniciando demonio en primer plano...[/cyan]",
+ "[cyan]Testing proxy connection to {host}:{port}...[/cyan]": "[cyan]Probando conexión al proxy {host}:{port}...[/cyan]",
+ "[cyan]Torrents:[/cyan] {num_torrents}": "[cyan]Torrents:[/cyan] {num_torrents}",
+ "[cyan]Updating filter lists from {count} URL(s)...[/cyan]": "[cyan]Actualizando listas de filtro desde {count} URL(s)...[/cyan]",
+ "[cyan]Upload:[/cyan] {rate:.2f} KiB/s": "[cyan]Subida:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Uptime:[/cyan] {uptime:.1f}s": "[cyan]Tiempo en marcha:[/cyan] {uptime:.1f}s",
+ "[cyan]Using custom IPC port: {port}[/cyan]": "[cyan]Usando puerto IPC personalizado: {port}[/cyan]",
+ "[cyan]Waiting for daemon to be ready...[/cyan]": "[cyan]Esperando a que el demonio esté listo...[/cyan]",
+ "[dim] uv run btbt daemon start --foreground[/dim]": "[dim] uv run btbt daemon start --foreground[/dim]",
+ "[dim]Daemon may still be starting. Use 'btbt daemon status' to check.[/dim]": "[dim]El demonio puede seguir iniciándose. Use «btbt daemon status» para comprobar.[/dim]",
+ "[dim]Info hash v1 (SHA-1): {hash}...[/dim]": "[dim]Info hash v1 (SHA-1): {hash}...[/dim]",
+ "[dim]Info hash v2 (SHA-256): {hash}...[/dim]": "[dim]Info hash v2 (SHA-256): {hash}...[/dim]",
+ "[dim]No active port mappings[/dim]": "[dim]Sin asignaciones de puerto activas[/dim]",
+ "[dim]Output: {path}[/dim]": "[dim]Salida: {path}[/dim]",
+ "[dim]Please restart manually: 'btbt daemon restart'[/dim]": "[dim]Reinicie manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Please restart the daemon manually: 'btbt daemon restart'[/dim]": "[dim]Reinicie el demonio manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Protocol: {method}[/dim]": "[dim]Protocolo: {method}[/dim]",
+ "[dim]See daemon log: {path}[/dim]": "[dim]Vea el registro del demonio: {path}[/dim]",
+ "[dim]Source: {path}[/dim]": "[dim]Origen: {path}[/dim]",
+ "[dim]Trackers: {count}[/dim]": "[dim]Trackers: {count}[/dim]",
+ "[dim]Try running with --foreground flag to see detailed error output:[/dim]": "[dim]Intente con la opción --foreground para ver el error detallado:[/dim]",
+ "[dim]Use 'btbt daemon status' to check daemon status[/dim]": "[dim]Use «btbt daemon status» para el estado del demonio[/dim]",
+ "[dim]Use -v flag for more details or check daemon logs[/dim]": "[dim]Use -v para más detalles o revise los registros del demonio[/dim]",
+ "[dim]Web seeds: {count}[/dim]": "[dim]Web seeds: {count}[/dim]",
+ "[green]ALLOWED[/green]": "[green]PERMITIDO[/green]",
+ "[green]Active Protocol:[/green] {method}": "[green]Protocolo activo:[/green] {method}",
+ "[green]Added alert rule {name}[/green]": "[green]Regla de alerta {name} añadida[/green]",
+ "[green]Added to IPFS:[/green] {cid}": "[green]Añadido a IPFS:[/green] {cid}",
+ "[green]Applying {preset} optimizations...[/green]": "[green]Aplicando optimizaciones {preset}...[/green]",
+ "[green]Benchmark results:[/green] {results}": "[green]Resultados de benchmark:[/green] {results}",
+ "[green]CA certificates path set to {path}. Configuration saved to {config_file}[/green]": "[green]Ruta de certificados CA establecida en {path}. Configuración guardada en {config_file}[/green]",
+ "[green]Checkpoint for {hash} is valid[/green]": "[green]Punto de control para {hash} es válido[/green]",
+ "[green]Checkpoint for {info_hash} is valid[/green]": "[green]Punto de control para {info_hash} es válido[/green]",
+ "[green]Checkpoint refreshed for {hash}[/green]": "[green]Punto de control actualizado para {hash}[/green]",
+ "[green]Checkpoint reloaded for {hash}[/green]": "[green]Punto de control recargado para {hash}[/green]",
+ "[green]Checkpoint saved for torrent[/green]": "[green]Punto de control guardado para el torrent[/green]",
+ "[green]Checkpoint saved[/green]": "[green]Punto de control guardado[/green]",
+ "[green]Checkpoint valid[/green]": "[green]Punto de control válido[/green]",
+ "[green]Cleared all active alerts[/green]": "[green]Se borraron todas las alertas activas[/green]",
+ "[green]Cleared queue[/green]": "[green]Cola vaciada[/green]",
+ "[green]Client certificate set. Configuration saved to {config_file}[/green]": "[green]Certificado de cliente establecido. Configuración guardada en {config_file}[/green]",
+ "[green]Connected to daemon[/green]": "[green]Conectado al demonio[/green]",
+ "[green]Content pinned[/green]": "[green]Contenido fijado[/green]",
+ "[green]Content saved to:[/green] {output}": "[green]Contenido guardado en:[/green] {output}",
+ "[green]DHT aggressive mode {mode} for torrent: {info_hash}[/green]": "[green]Modo DHT agresivo {mode} para torrent: {info_hash}[/green]",
+ "[green]Daemon is running[/green] (PID: {pid})": "[green]El demonio está en ejecución[/green] (PID: {pid})",
+ "[green]Daemon restarted successfully[/green]": "[green]Demonio reiniciado correctamente[/green]",
+ "[green]Daemon stopped gracefully[/green]": "[green]Demonio detenido correctamente[/green]",
+ "[green]Daemon stopped[/green]": "[green]Demonio detenido[/green]",
+ "[green]Deleted checkpoint for {hash}[/green]": "[green]Punto de control eliminado para {hash}[/green]",
+ "[green]Deleted checkpoint for {info_hash}[/green]": "[green]Punto de control eliminado para {info_hash}[/green]",
+ "[green]Deselected all files.[/green]": "[green]Todos los archivos deseleccionados.[/green]",
+ "[green]Deselected all files[/green]": "[green]Todos los archivos deseleccionados[/green]",
+ "[green]Deselected {count} file(s)[/green]": "[green]Deseleccionado(s) {count} archivo(s)[/green]",
+ "[green]External IP:[/green] {ip}": "[green]IP externa:[/green] {ip}",
+ "[green]Force started {count} torrent(s)[/green]": "[green]Forzado el inicio de {count} torrent(s)[/green]",
+ "[green]Found checkpoint for: {torrent_name}[/green]": "[green]Punto de control encontrado para: {torrent_name}[/green]",
+ "[green]Integrity verification passed: {count} pieces verified[/green]": "[green]Verificación de integridad correcta: {count} piezas verificadas[/green]",
+ "[green]Loaded alert rules from {path}[/green]": "[green]Reglas de alerta cargadas desde {path}[/green]",
+ "[green]Loaded {count} alert rules from {path}[/green]": "[green]Cargadas {count} reglas de alerta desde {path}[/green]",
+ "[green]Locale set to: {locale_code}[/green]": "[green]Configuración regional establecida en: {locale_code}[/green]",
+ "[green]Magnet link added to daemon: {info_hash}[/green]": "[green]Enlace magnet añadido al demonio: {info_hash}[/green]",
+ "[green]Moved to position {position}[/green]": "[green]Movido a la posición {position}[/green]",
+ "[green]Network configuration looks optimal![/green]": "[green]¡La configuración de red parece óptima![/green]",
+ "[green]No checkpoints older than {days} days found[/green]": "[green]No hay puntos de control con más de {days} días[/green]",
+ "[green]Optimizations applied successfully![/green]\n[yellow]Note: Some changes may require restart to take effect.[/yellow]": "[green]¡Optimizaciones aplicadas correctamente![/green]\n[yellow]Nota: algunos cambios pueden requerir reinicio.[/yellow]",
+ "[green]Optimizations saved to {path}[/green]": "[green]Optimizaciones guardadas en {path}[/green]",
+ "[green]PEX refreshed for torrent: {info_hash}[/green]": "[green]PEX actualizado para torrent: {info_hash}[/green]",
+ "[green]Paused torrent[/green]": "[green]Torrent pausado[/green]",
+ "[green]Paused {count} torrent(s)[/green]": "[green]Pausado(s) {count} torrent(s)[/green]",
+ "[green]Peer validation hooks are enabled by configuration[/green]": "[green]Los hooks de validación de pares están habilitados por configuración[/green]",
+ "[green]Per-peer rate limit for {peer_key}: {limit}[/green]": "[green]Límite de velocidad por par para {peer_key}: {limit}[/green]",
+ "[green]Per-peer rate limit set: {peer_key} = {upload} KiB/s[/green]": "[green]Límite por par establecido: {peer_key} = {upload} KiB/s[/green]",
+ "[green]Performing basic configuration scan...[/green]": "[green]Realizando análisis básico de configuración...[/green]",
+ "[green]Pinned:[/green] {cid}": "[green]Fijado:[/green] {cid}",
+ "[green]Proxy configuration saved to {config_file}[/green]": "[green]Configuración del proxy guardada en {config_file}[/green]",
+ "[green]Proxy configuration updated successfully[/green]": "[green]Configuración del proxy actualizada correctamente[/green]",
+ "[green]Proxy has been disabled[/green]": "[green]El proxy se ha desactivado[/green]",
+ "[green]Removed alert rule {name}[/green]": "[green]Regla de alerta {name} eliminada[/green]",
+ "[green]Removed torrent from queue[/green]": "[green]Torrent quitado de la cola[/green]",
+ "[green]Reset all options for torrent {hash}[/green]": "[green]Todas las opciones restablecidas para torrent {hash}[/green]",
+ "[green]Reset {key} for torrent {hash}[/green]": "[green]Restablecido {key} para torrent {hash}[/green]",
+ "[green]Restored checkpoint for: {name}[/green]\nInfo hash: {hash}": "[green]Punto de control restaurado para: {name}[/green]\nHash de información: {hash}",
+ "[green]Resume data structure is valid[/green]": "[green]La estructura de datos de reanudación es válida[/green]",
+ "[green]Resumed torrent[/green]": "[green]Torrent reanudado[/green]",
+ "[green]Resumed {count} torrent(s)[/green]": "[green]Reanudado(s) {count} torrent(s)[/green]",
+ "[green]Resuming from checkpoint[/green]": "[green]Reanudando desde el punto de control[/green]",
+ "[green]SSL certificate verification enabled. Configuration saved to {config_file}[/green]": "[green]Verificación de certificado SSL habilitada. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for peers disabled. Configuration saved to {config_file}[/green]": "[green]SSL para pares desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for peers enabled (experimental). Configuration saved to {config_file}[/green]": "[green]SSL para pares habilitado (experimental). Configuración guardada en {config_file}[/green]",
+ "[green]SSL for trackers disabled. Configuration saved to {config_file}[/green]": "[green]SSL para trackers desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for trackers enabled. Configuration saved to {config_file}[/green]": "[green]SSL para trackers habilitado. Configuración guardada en {config_file}[/green]",
+ "[green]Saved alert rules to {path}[/green]": "[green]Reglas de alerta guardadas en {path}[/green]",
+ "[green]Saved resume data for {hash}[/green]": "[green]Datos de reanudación guardados para {hash}[/green]",
+ "[green]Selected all files[/green]": "[green]Todos los archivos seleccionados[/green]",
+ "[green]Selected {count} file(s).[/green]": "[green]Seleccionado(s) {count} archivo(s).[/green]",
+ "[green]Selected {count} file(s)[/green]": "[green]Seleccionado(s) {count} archivo(s)[/green]",
+ "[green]Set file {index} priority to {priority}[/green]": "[green]Prioridad del archivo {index} establecida en {priority}[/green]",
+ "[green]Set priority to {priority}[/green]": "[green]Prioridad establecida en {priority}[/green]",
+ "[green]Set rate limit for {count} peers: {upload} KiB/s[/green]": "[green]Límite de velocidad para {count} pares: {upload} KiB/s[/green]",
+ "[green]Set {key} = {value} for torrent {hash}[/green]": "[green]Establecido {key} = {value} para torrent {hash}[/green]",
+ "[green]Successfully resumed download: {hash}[/green]": "[green]Descarga reanudada correctamente: {hash}[/green]",
+ "[green]Successfully resumed download: {resumed_info_hash}[/green]": "[green]Descarga reanudada correctamente: {resumed_info_hash}[/green]",
+ "[green]TLS protocol version set to {version}. Configuration saved to {config_file}[/green]": "[green]Versión de protocolo TLS establecida en {version}. Configuración guardada en {config_file}[/green]",
+ "[green]Tested rule {name} with value {value}[/green]": "[green]Regla {name} probada con valor {value}[/green]",
+ "[green]Torrent added to daemon: {info_hash}[/green]": "[green]Torrent añadido al demonio: {info_hash}[/green]",
+ "[green]Torrent cancelled: {info_hash}{checkpoint_info}[/green]": "[green]Torrent cancelado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent force started: {info_hash}[/green]": "[green]Torrent forzado a iniciar: {info_hash}[/green]",
+ "[green]Torrent paused: {info_hash}{checkpoint_info}[/green]": "[green]Torrent pausado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent resumed: {info_hash}{checkpoint_info}[/green]": "[green]Torrent reanudado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Tracker added: {url} to torrent {info_hash}[/green]": "[green]Tracker {url} añadido al torrent {info_hash}[/green]",
+ "[green]Tracker removed: {url} from torrent {info_hash}[/green]": "[green]Tracker {url} quitado del torrent {info_hash}[/green]",
+ "[green]Unpinned:[/green] {cid}": "[green]Desfijado:[/green] {cid}",
+ "[green]Updated {key} to {value}[/green]": "[green]Actualizado {key} a {value}[/green]",
+ "[green]Wrote metrics to {path}[/green]": "[green]Métricas escritas en {path}[/green]",
+ "[green]{message}: {config_file}[/green]": "[green]{message}: {config_file}[/green]",
+ "[green]✓ Port mapping removed[/green]": "[green]✓ Asignación de puerto eliminada[/green]",
+ "[green]✓ Port mapping successful![/green]": "[green]✓ ¡Asignación de puerto correcta![/green]",
+ "[green]✓ Port mappings refreshed[/green]": "[green]✓ Asignaciones de puerto actualizadas[/green]",
+ "[green]✓ Proxy connection test successful[/green]": "[green]✓ Prueba de conexión al proxy correcta[/green]",
+ "[green]✓ Torrent created successfully: {path}[/green]": "[green]✓ Torrent creado correctamente: {path}[/green]",
+ "[green]✓[/green] Added filter rule: {ip_range} ({mode})": "[green]✓[/green] Regla de filtro añadida: {ip_range} ({mode})",
+ "[green]✓[/green] Added peer {peer_id} to allowlist": "[green]✓[/green] Par {peer_id} añadido a la lista permitida",
+ "[green]✓[/green] Added peer {peer_id} to allowlist with alias '{alias}'": "[green]✓[/green] Par {peer_id} añadido a la lista permitida con alias '{alias}'",
+ "[green]✓[/green] Cleaned {cleaned} unused chunks": "[green]✓[/green] Limpiados {cleaned} trozos no usados",
+ "[green]✓[/green] Configuration saved to {file}": "[green]✓[/green] Configuración guardada en {file}",
+ "[green]✓[/green] Daemon process started (PID {pid})": "[green]✓[/green] Proceso del demonio iniciado (PID {pid})",
+ "[green]✓[/green] Daemon started successfully (PID {pid}, took {elapsed:.1f}s)": "[green]✓[/green] Demonio iniciado correctamente (PID {pid}, tardó {elapsed:.1f}s)",
+ "[green]✓[/green] Folder sync started": "[green]✓[/green] Sincronización de carpeta iniciada",
+ "[green]✓[/green] Generated .tonic file: {file}": "[green]✓[/green] Archivo .tonic generado: {file}",
+ "[green]✓[/green] Generated new API key for daemon": "[green]✓[/green] Nueva clave de API generada para el demonio",
+ "[green]✓[/green] Generated tonic?: link:": "[green]✓[/green] Generated tonic?: link:",
+ "[green]✓[/green] Loaded {loaded} rules from {file_path}": "[green]✓[/green] Cargadas {loaded} reglas desde {file_path}",
+ "[green]✓[/green] Loaded {total_loaded} total rules": "[green]✓[/green] Cargadas {total_loaded} reglas en total",
+ "[green]✓[/green] Removed alias for peer {peer_id}": "[green]✓[/green] Alias eliminado para el par {peer_id}",
+ "[green]✓[/green] Removed filter rule: {ip_range}": "[green]✓[/green] Regla de filtro eliminada: {ip_range}",
+ "[green]✓[/green] Removed peer {peer_id} from allowlist": "[green]✓[/green] Par {peer_id} quitado de la lista permitida",
+ "[green]✓[/green] Set alias '{alias}' for peer {peer_id}": "[green]✓[/green] Alias '{alias}' establecido para el par {peer_id}",
+ "[green]✓[/green] Set {key} = {value}": "[green]✓[/green] Establecido {key} = {value}",
+ "[green]✓[/green] Successfully updated {count} filter list(s)": "[green]✓[/green] Actualizadas correctamente {count} lista(s) de filtro",
+ "[green]✓[/green] Sync mode updated": "[green]✓[/green] Modo de sincronización actualizado",
+ "[green]✓[/green] Tonic link:": "[green]✓[/green] Enlace tonic:",
+ "[green]✓[/green] Updated config file: {file}": "[green]✓[/green] Archivo de configuración actualizado: {file}",
+ "[green]✓[/green] Xet protocol enabled": "[green]✓[/green] Protocolo Xet habilitado",
+ "[green]✓[/green] uTP configuration reset to defaults": "[green]✓[/green] Configuración uTP restablecida a valores predeterminados",
+ "[green]✓[/green] uTP transport enabled": "[green]✓[/green] Transporte uTP habilitado",
+ "[red]--name is required to remove a rule[/red]": "[red]Se requiere --name para quitar una regla[/red]",
+ "[red]--name is required to test a rule[/red]": "[red]Se requiere --name para probar una regla[/red]",
+ "[red]--name, --metric and --condition are required to add a rule[/red]": "[red]Se requieren --name, --metric y --condition para añadir una regla[/red]",
+ "[red]--value is required with --test[/red]": "[red]Se requiere --value con --test[/red]",
+ "[red]BLOCKED[/red]": "[red]BLOQUEADO[/red]",
+ "[red]Certificate file does not exist: {path}[/red]": "[red]El archivo de certificado no existe: {path}[/red]",
+ "[red]Certificate path must be a file: {path}[/red]": "[red]La ruta del certificado debe ser un archivo: {path}[/red]",
+ "[red]Configuration key not found: {key}[/red]": "[red]Clave de configuración no encontrada: {key}[/red]",
+ "[red]Content not found: {cid}[/red]": "[red]Contenido no encontrado: {cid}[/red]",
+ "[red]Daemon is not running[/red]": "[red]El demonio no está en ejecución[/red]",
+ "[red]Daemon process crashed[/red]": "[red]El proceso del demonio falló[/red]",
+ "[red]Dashboard error: {e}[/red]": "[red]Error del panel: {e}[/red]",
+ "[red]Directories not yet supported[/red]": "[red]Los directorios aún no están soportados[/red]",
+ "[red]Error adding content: {e}[/red]": "[red]Error al añadir contenido: {e}[/red]",
+ "[red]Error adding peer to allowlist: {e}[/red]": "[red]Error al añadir par a la lista permitida: {e}[/red]",
+ "[red]Error disabling SSL for peers: {e}[/red]": "[red]Error al desactivar SSL para pares: {e}[/red]",
+ "[red]Error disabling SSL for trackers: {e}[/red]": "[red]Error al desactivar SSL para trackers: {e}[/red]",
+ "[red]Error disabling Xet protocol: {e}[/red]": "[red]Error al desactivar el protocolo Xet: {e}[/red]",
+ "[red]Error disabling certificate verification: {e}[/red]": "[red]Error al desactivar la verificación de certificados: {e}[/red]",
+ "[red]Error during cleanup: {e}[/red]": "[red]Error durante la limpieza: {e}[/red]",
+ "[red]Error enabling SSL for peers: {e}[/red]": "[red]Error al activar SSL para pares: {e}[/red]",
+ "[red]Error enabling SSL for trackers: {e}[/red]": "[red]Error al activar SSL para trackers: {e}[/red]",
+ "[red]Error enabling Xet protocol: {e}[/red]": "[red]Error al activar el protocolo Xet: {e}[/red]",
+ "[red]Error enabling certificate verification: {e}[/red]": "[red]Error al activar la verificación de certificados: {e}[/red]",
+ "[red]Error ensuring daemon is running: {e}[/red]": "[red]Error al asegurar que el demonio está en ejecución: {e}[/red]",
+ "[red]Error generating .tonic file: {e}[/red]": "[red]Error al generar el archivo .tonic: {e}[/red]",
+ "[red]Error generating tonic link: {e}[/red]": "[red]Error al generar el enlace tonic: {e}[/red]",
+ "[red]Error getting SSL status: {e}[/red]": "[red]Error al obtener el estado SSL: {e}[/red]",
+ "[red]Error getting Xet status: {e}[/red]": "[red]Error al obtener el estado Xet: {e}[/red]",
+ "[red]Error getting content: {e}[/red]": "[red]Error al obtener el contenido: {e}[/red]",
+ "[red]Error getting peers: {e}[/red]": "[red]Error al obtener los pares: {e}[/red]",
+ "[red]Error getting stats: {e}[/red]": "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error getting status: {e}[/red]": "[red]Error al obtener el estado: {e}[/red]",
+ "[red]Error getting sync mode: {e}[/red]": "[red]Error al obtener el modo de sincronización: {e}[/red]",
+ "[red]Error listing aliases: {e}[/red]": "[red]Error al listar alias: {e}[/red]",
+ "[red]Error listing allowlist: {e}[/red]": "[red]Error al listar la lista permitida: {e}[/red]",
+ "[red]Error pinning content: {e}[/red]": "[red]Error al fijar contenido: {e}[/red]",
+ "[red]Error reading authenticated swarm status: {e}[/red]": "[red]Error al leer el estado del enjambre autenticado: {e}[/red]",
+ "[red]Error removing alias: {e}[/red]": "[red]Error al eliminar alias: {e}[/red]",
+ "[red]Error removing peer from allowlist: {e}[/red]": "[red]Error al quitar par de la lista permitida: {e}[/red]",
+ "[red]Error restarting daemon: {e}[/red]": "[red]Error al reiniciar el demonio: {e}[/red]",
+ "[red]Error retrieving cache info: {e}[/red]": "[red]Error al obtener información de caché: {e}[/red]",
+ "[red]Error retrieving disk statistics: {error}[/red]": "[red]Error al obtener estadísticas de disco: {error}[/red]",
+ "[red]Error retrieving network statistics: {error}[/red]": "[red]Error al obtener estadísticas de red: {error}[/red]",
+ "[red]Error retrieving stats: {e}[/red]": "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error setting CA certificates path: {e}[/red]": "[red]Error al establecer la ruta de certificados CA: {e}[/red]",
+ "[red]Error setting alias: {e}[/red]": "[red]Error al establecer alias: {e}[/red]",
+ "[red]Error setting client certificate: {e}[/red]": "[red]Error al establecer certificado de cliente: {e}[/red]",
+ "[red]Error setting protocol version: {e}[/red]": "[red]Error al establecer la versión de protocolo: {e}[/red]",
+ "[red]Error setting sync mode: {e}[/red]": "[red]Error al establecer el modo de sincronización: {e}[/red]",
+ "[red]Error starting sync: {e}[/red]": "[red]Error al iniciar la sincronización: {e}[/red]",
+ "[red]Error unpinning content: {e}[/red]": "[red]Error al desfijar contenido: {e}[/red]",
+ "[red]Error updating authenticated swarm mode: {e}[/red]": "[red]Error al actualizar el modo de enjambre autenticado: {e}[/red]",
+ "[red]Error updating configuration: {error}[/red]": "[red]Error al actualizar la configuración: {error}[/red]",
+ "[red]Error updating discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento: {e}[/red]",
+ "[red]Error updating parse-policy behavior: {e}[/red]": "[red]Error al actualizar el comportamiento de parse-policy: {e}[/red]",
+ "[red]Error updating strict discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento estricto: {e}[/red]",
+ "[red]Error updating trusted IDs: {e}[/red]": "[red]Error al actualizar los ID de confianza: {e}[/red]",
+ "[red]Error: Cannot specify both --hybrid and --v1[/red]": "[red]Error: no puede especificar --hybrid y --v1 a la vez[/red]",
+ "[red]Error: Cannot specify both --v2 and --hybrid[/red]": "[red]Error: no puede especificar --v2 y --hybrid a la vez[/red]",
+ "[red]Error: Cannot specify both --v2 and --v1[/red]": "[red]Error: no puede especificar --v2 y --v1 a la vez[/red]",
+ "[red]Error: Configuration not available[/red]": "[red]Error: configuración no disponible[/red]",
+ "[red]Error: Failed to get daemon status: {error}[/red]": "[red]Error: no se pudo obtener el estado del demonio: {error}[/red]",
+ "[red]Error: Info hash must be 40 hex characters[/red]": "[red]Error: el info hash debe tener 40 caracteres hexadecimales[/red]",
+ "[red]Error: Invalid torrent file: {torrent_file}[/red]": "[red]Error: archivo torrent no válido: {torrent_file}[/red]",
+ "[red]Error: Network configuration not available[/red]": "[red]Error: configuración de red no disponible[/red]",
+ "[red]Error: Piece length must be a power of 2[/red]": "[red]Error: la longitud de pieza debe ser potencia de 2[/red]",
+ "[red]Error: Piece length must be at least 16 KiB (16384 bytes)[/red]": "[red]Error: la longitud de pieza debe ser al menos 16 KiB (16384 bytes)[/red]",
+ "[red]Error: Source directory is empty[/red]": "[red]Error: el directorio de origen está vacío[/red]",
+ "[red]Error: Source path does not exist: {path}[/red]": "[red]Error: la ruta de origen no existe: {path}[/red]",
+ "[red]Error: {e}[/red]": "[red]Error: {e}[/red]",
+ "[red]Error:[/red] Invalid value for {key}: {value}": "[red]Error:[/red] Valor no válido para {key}: {value}",
+ "[red]Error:[/red] Unknown configuration key: {key}": "[red]Error:[/red] Clave de configuración desconocida: {key}",
+ "[red]Export not available in daemon mode[/red]": "[red]Exportación no disponible en modo demonio[/red]",
+ "[red]Failed to add magnet: {error}[/red]": "[red]No se pudo añadir el magnet: {error}[/red]",
+ "[red]Failed to cancel: {error}[/red]": "[red]No se pudo cancelar: {error}[/red]",
+ "[red]Failed to clear active alerts: {e}[/red]": "[red]No se pudieron borrar las alertas activas: {e}[/red]",
+ "[red]Failed to create session[/red]": "[red]No se pudo crear la sesión[/red]",
+ "[red]Failed to disable proxy: {e}[/red]": "[red]No se pudo desactivar el proxy: {e}[/red]",
+ "[red]Failed to force start: {error}[/red]": "[red]No se pudo forzar el inicio: {error}[/red]",
+ "[red]Failed to get proxy status: {e}[/red]": "[red]No se pudo obtener el estado del proxy: {e}[/red]",
+ "[red]Failed to load alert rules: {e}[/red]": "[red]No se pudieron cargar las reglas de alerta: {e}[/red]",
+ "[red]Failed to load rules: {e}[/red]": "[red]No se pudieron cargar las reglas: {e}[/red]",
+ "[red]Failed to pause: {error}[/red]": "[red]No se pudo pausar: {error}[/red]",
+ "[red]Failed to reset options[/red]": "[red]No se pudieron restablecer las opciones[/red]",
+ "[red]Failed to restart daemon[/red]": "[red]No se pudo reiniciar el demonio[/red]",
+ "[red]Failed to resume: {error}[/red]": "[red]No se pudo reanudar: {error}[/red]",
+ "[red]Failed to run tests: {e}[/red]": "[red]No se pudieron ejecutar las pruebas: {e}[/red]",
+ "[red]Failed to save rules: {e}[/red]": "[red]No se pudieron guardar las reglas: {e}[/red]",
+ "[red]Failed to set option[/red]": "[red]No se pudo establecer la opción[/red]",
+ "[red]Failed to set proxy configuration: {e}[/red]": "[red]No se pudo establecer la configuración del proxy: {e}[/red]",
+ "[red]Failed to start daemon. Cannot proceed without daemon.[/red]\n[yellow]Please check:[/yellow]\n 1. Daemon logs for startup errors\n 2. Port conflicts (check if port is already in use)\n 3. Permissions (ensure you have permission to start daemon)\n\n[cyan]To start daemon manually: 'btbt daemon start'[/cyan]": "[red]No se pudo iniciar el demonio. No se puede continuar sin demonio.[/red]\n[yellow]Compruebe:[/yellow]\n 1. Registros del demonio por errores de inicio\n 2. Conflictos de puerto (¿el puerto está en uso?)\n 3. Permisos (¿puede iniciar el demonio?)\n\n[cyan]Para iniciar manualmente: «btbt daemon start»[/cyan]",
+ "[red]Failed to stop: {error}[/red]": "[red]No se pudo detener: {error}[/red]",
+ "[red]Failed to test proxy: {e}[/red]": "[red]No se pudo probar el proxy: {e}[/red]",
+ "[red]Failed to test rule: {e}[/red]": "[red]No se pudo probar la regla: {e}[/red]",
+ "[red]Failed: {error}[/red]": "[red]Fallo: {error}[/red]",
+ "[red]File not found: {e}[/red]": "[red]Archivo no encontrado: {e}[/red]",
+ "[red]IP filter not initialized. Please enable it in configuration.[/red]": "[red]Filtro IP no inicializado. Habilítelo en la configuración.[/red]",
+ "[red]IP filter not initialized.[/red]": "[red]Filtro IP no inicializado.[/red]",
+ "[red]IPFS protocol not available[/red]": "[red]Protocolo IPFS no disponible[/red]",
+ "[red]Import not available in daemon mode[/red]": "[red]Importación no disponible en modo demonio[/red]",
+ "[red]Invalid IP address: {ip}[/red]": "[red]Dirección IP no válida: {ip}[/red]",
+ "[red]Invalid info hash format[/red]": "[red]Formato de info hash no válido[/red]",
+ "[red]Invalid info hash: {hash}[/red]": "[red]Info hash no válido: {hash}[/red]",
+ "[red]Invalid magnet link: {e}[/red]": "[red]Enlace magnet no válido: {e}[/red]",
+ "[red]Invalid public key: {e}[/red]": "[red]Clave pública no válida: {e}[/red]",
+ "[red]Invalid value for {key}: {error}[/red]": "[red]Valor no válido para {key}: {error}[/red]",
+ "[red]Key file does not exist: {path}[/red]": "[red]El archivo de clave no existe: {path}[/red]",
+ "[red]Key path must be a file: {path}[/red]": "[red]La ruta de la clave debe ser un archivo: {path}[/red]",
+ "[red]Metrics error: {e}[/red]": "[red]Error de métricas: {e}[/red]",
+ "[red]No stats found for CID: {cid}[/red]": "[red]No hay estadísticas para el CID: {cid}[/red]",
+ "[red]Path does not exist: {path}[/red]": "[red]La ruta no existe: {path}[/red]",
+ "[red]Path must be a file or directory: {path}[/red]": "[red]La ruta debe ser un archivo o directorio: {path}[/red]",
+ "[red]Peer {peer_id} not found in allowlist[/red]": "[red]Par {peer_id} no encontrado en la lista permitida[/red]",
+ "[red]Proxy error: {e}[/red]": "[red]Error del proxy: {e}[/red]",
+ "[red]Proxy host and port must be configured[/red]": "[red]Deben configurarse host y puerto del proxy[/red]",
+ "[red]Rule not found: {name}[/red]": "[red]Regla no encontrada: {name}[/red]",
+ "[red]Specify CID or use --all[/red]": "[red]Especifique CID o use --all[/red]",
+ "[red]Torrent not found: {hash}[/red]": "[red]Torrent no encontrado: {hash}[/red]",
+ "[red]Unexpected error during resume: {e}[/red]": "[red]Error inesperado al reanudar: {e}[/red]",
+ "[red]Unknown configuration key: {key}[/red]": "[red]Clave de configuración desconocida: {key}[/red]",
+ "[red]Validation error: {e}[/red]": "[red]Error de validación: {e}[/red]",
+ "[red]{msg}[/red]": "[red]{msg}[/red]",
+ "[red]✗ Failed to remove port mapping[/red]": "[red]✗ No se pudo quitar la asignación de puerto[/red]",
+ "[red]✗ Port mapping failed[/red]": "[red]✗ Falló la asignación de puerto[/red]",
+ "[red]✗ Proxy connection test failed[/red]": "[red]✗ Falló la prueba de conexión al proxy[/red]",
+ "[red]✗[/red] Daemon is already running with PID {pid}": "[red]✗[/red] El demonio ya está en ejecución con PID {pid}",
+ "[red]✗[/red] Daemon process (PID {pid}) crashed during startup (after {elapsed:.1f}s)": "[red]✗[/red] El proceso del demonio (PID {pid}) falló durante el inicio (tras {elapsed:.1f}s)",
+ "[red]✗[/red] Daemon process (PID {pid}) exited immediately after starting": "[red]✗[/red] El proceso del demonio (PID {pid}) salió inmediatamente tras iniciar",
+ "[red]✗[/red] Failed to add filter rule: {ip_range}": "[red]✗[/red] No se pudo añadir la regla de filtro: {ip_range}",
+ "[red]✗[/red] Failed to load rules from {file_path}": "[red]✗[/red] No se pudieron cargar reglas desde {file_path}",
+ "[red]✗[/red] Failed to start daemon: {e}": "[red]✗[/red] No se pudo iniciar el demonio: {e}",
+ "[red]✗[/red] Failed to update filter lists": "[red]✗[/red] No se pudieron actualizar las listas de filtro",
+ "[yellow]1. Network Connectivity[/yellow]": "[yellow]1. Conectividad de red[/yellow]",
+ "[yellow]API key not found in config, cannot get detailed status[/yellow]": "[yellow]No se encontró clave de API en la configuración; no se puede obtener estado detallado[/yellow]",
+ "[yellow]Active Protocol:[/yellow] None (not discovered)": "[yellow]Protocolo activo:[/yellow] Ninguno (no descubierto)",
+ "[yellow]Allowlist is empty[/yellow]": "[yellow]La lista permitida está vacía[/yellow]",
+ "[yellow]Authenticated swarm setting updated (configuration not persisted - no config file)[/yellow]": "[yellow]Ajuste de enjambre autenticado actualizado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Authenticated swarm setting updated (test mode, write skipped)[/yellow]": "[yellow]Ajuste de enjambre autenticado actualizado (modo prueba, escritura omitida)[/yellow]",
+ "[yellow]Authenticated swarms not configured[/yellow]": "[yellow]Enjambres autenticados no configurados[/yellow]",
+ "[yellow]Automatic repair not implemented[/yellow]": "[yellow]Reparación automática no implementada[/yellow]",
+ "[yellow]CA certificates path set to {path} (configuration not persisted - no config file)[/yellow]": "[yellow]Ruta de certificados CA en {path} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]CA certificates path set to {path} (skipped write in test mode)[/yellow]": "[yellow]Ruta de certificados CA en {path} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Checkpoint cannot be auto-resumed - no torrent source found[/yellow]": "[yellow]El punto de control no puede reanudarse solo: no se encontró fuente del torrent[/yellow]",
+ "[yellow]Checkpoint for {hash} is missing or invalid[/yellow]": "[yellow]El punto de control para {hash} falta o no es válido[/yellow]",
+ "[yellow]Checkpoint missing/invalid[/yellow]": "[yellow]Punto de control ausente o no válido[/yellow]",
+ "[yellow]Client certificate set (configuration not persisted - no config file)[/yellow]": "[yellow]Certificado de cliente establecido (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Client certificate set (skipped write in test mode)[/yellow]": "[yellow]Certificado de cliente establecido (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Configuration changes require daemon restart.[/yellow]": "[yellow]Los cambios de configuración requieren reiniciar el demonio.[/yellow]",
+ "[yellow]Could not deselect: {error}[/yellow]": "[yellow]No se pudo deseleccionar: {error}[/yellow]",
+ "[yellow]Could not get detailed status via IPC[/yellow]": "[yellow]No se pudo obtener estado detallado por IPC[/yellow]",
+ "[yellow]Could not save to config file: {error}[/yellow]": "[yellow]No se pudo guardar en el archivo de configuración: {error}[/yellow]",
+ "[yellow]Disk I/O manager not running. Statistics unavailable.[/yellow]": "[yellow]El gestor de E/S de disco no está en ejecución. Estadísticas no disponibles.[/yellow]",
+ "[yellow]Dry run: Would clean chunks older than {days} days[/yellow]": "[yellow]Simulación: se limpiarían trozos de más de {days} días[/yellow]",
+ "[yellow]External IP not available[/yellow]": "[yellow]IP externa no disponible[/yellow]",
+ "[yellow]External IP:[/yellow] Not available": "[yellow]IP externa:[/yellow] No disponible",
+ "[yellow]Failed to generate tonic link[/yellow]": "[yellow]No se pudo generar el enlace tonic[/yellow]",
+ "[yellow]Failed to move torrent[/yellow]": "[yellow]No se pudo mover el torrent[/yellow]",
+ "[yellow]Failed to refresh checkpoint for {hash}[/yellow]": "[yellow]No se pudo actualizar el punto de control para {hash}[/yellow]",
+ "[yellow]Failed to reload checkpoint for {hash}[/yellow]": "[yellow]No se pudo recargar el punto de control para {hash}[/yellow]",
+ "[yellow]Fast resume is disabled[/yellow]": "[yellow]Reanudación rápida desactivada[/yellow]",
+ "[yellow]Found checkpoint for: {name}[/yellow]": "[yellow]Punto de control encontrado para: {name}[/yellow]",
+ "[yellow]Found checkpoint for: {torrent_name}[/yellow]": "[yellow]Punto de control encontrado para: {torrent_name}[/yellow]",
+ "[yellow]Full rehash not implemented in CLI; use resume to trigger piece verification[/yellow]": "[yellow]Rehash completo no implementado en CLI; use reanudar para verificar piezas[/yellow]",
+ "[yellow]IP filter not initialized or disabled.[/yellow]": "[yellow]Filtro IP no inicializado o desactivado.[/yellow]",
+ "[yellow]Integrity verification failed: {count} pieces failed[/yellow]": "[yellow]Falló la verificación de integridad: {count} piezas erróneas[/yellow]",
+ "[yellow]NAT Status[/yellow]": "[yellow]Estado NAT[/yellow]",
+ "[yellow]Network optimizer not available[/yellow]": "[yellow]Optimizador de red no disponible[/yellow]",
+ "[yellow]Network statistics not available[/yellow]": "[yellow]Estadísticas de red no disponibles[/yellow]",
+ "[yellow]No active alerts[/yellow]": "[yellow]No hay alertas activas[/yellow]",
+ "[yellow]No alert rules defined[/yellow]": "[yellow]No hay reglas de alerta definidas[/yellow]",
+ "[yellow]No alias found for peer {peer_id}[/yellow]": "[yellow]No hay alias para el par {peer_id}[/yellow]",
+ "[yellow]No aliases found in allowlist[/yellow]": "[yellow]No hay alias en la lista permitida[/yellow]",
+ "[yellow]No authenticated swarms configuration found[/yellow]": "[yellow]No hay configuración de enjambres autenticados[/yellow]",
+ "[yellow]No cached scrape results[/yellow]": "[yellow]No hay resultados de scrape en caché[/yellow]",
+ "[yellow]No checkpoint found for {hash}[/yellow]": "[yellow]No hay punto de control para {hash}[/yellow]",
+ "[yellow]No checkpoint found for {info_hash}[/yellow]": "[yellow]No hay punto de control para {info_hash}[/yellow]",
+ "[yellow]No chunks in cache[/yellow]": "[yellow]No hay trozos en caché[/yellow]",
+ "[yellow]No config file found - configuration not persisted[/yellow]": "[yellow]No se encontró archivo de configuración — no se persistió[/yellow]",
+ "[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]": "[yellow]No hay lista de archivos en {timeout}s; se continúa con selección predeterminada.[/yellow]",
+ "[yellow]No filter URLs configured.[/yellow]": "[yellow]No hay URL de filtro configuradas.[/yellow]",
+ "[yellow]No filter rules configured.[/yellow]": "[yellow]No hay reglas de filtro configuradas.[/yellow]",
+ "[yellow]No optimizations were applied (already optimal or unsupported)[/yellow]": "[yellow]No se aplicaron optimizaciones (ya óptimo o no soportado)[/yellow]",
+ "[yellow]No performance action specified[/yellow]": "[yellow]No se especificó acción de rendimiento[/yellow]",
+ "[yellow]No recover action specified[/yellow]": "[yellow]No se especificó acción de recuperación[/yellow]",
+ "[yellow]No resume data found in checkpoint[/yellow]": "[yellow]No hay datos de reanudación en el punto de control[/yellow]",
+ "[yellow]No security action specified[/yellow]": "[yellow]No se especificó acción de seguridad[/yellow]",
+ "[yellow]No security configuration loaded[/yellow]": "[yellow]No hay configuración de seguridad cargada[/yellow]",
+ "[yellow]No valid indices, keeping default selection.[/yellow]": "[yellow]Índices no válidos; se mantiene la selección predeterminada.[/yellow]",
+ "[yellow]Non-interactive mode, starting fresh download[/yellow]": "[yellow]Modo no interactivo; iniciando descarga nueva[/yellow]",
+ "[yellow]Note: This change is temporary and will be lost on restart. Use config file for persistent changes.[/yellow]": "[yellow]Nota: este cambio es temporal y se perderá al reiniciar. Use archivo de config. para persistir.[/yellow]",
+ "[yellow]Note: Update config file to persist locale setting[/yellow]": "[yellow]Nota: actualice el archivo de configuración para persistir la configuración regional[/yellow]",
+ "[yellow]Note:[/yellow] Configuration change is runtime-only": "[yellow]Nota:[/yellow] El cambio de configuración solo aplica en tiempo de ejecución",
+ "[yellow]Optimization cancelled[/yellow]": "[yellow]Optimización cancelada[/yellow]",
+ "[yellow]Peer {peer_id} not found in allowlist[/yellow]": "[yellow]Par {peer_id} no encontrado en la lista permitida[/yellow]",
+ "[yellow]Please provide the original torrent file or magnet link[/yellow]": "[yellow]Proporcione el archivo torrent original o el enlace magnet[/yellow]",
+ "[yellow]Please use --v2 or --hybrid flags for now.[/yellow]": "[yellow]Por ahora use las opciones --v2 o --hybrid.[/yellow]",
+ "[yellow]Proxy configuration not found[/yellow]": "[yellow]Configuración del proxy no encontrada[/yellow]",
+ "[yellow]Proxy configuration updated (skipped write in test mode)[/yellow]": "[yellow]Configuración del proxy actualizada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Proxy has been disabled (skipped write in test mode)[/yellow]": "[yellow]El proxy se desactivó (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Proxy is not enabled[/yellow]": "[yellow]El proxy no está habilitado[/yellow]",
+ "[yellow]Real-time monitoring not yet implemented[/yellow]": "[yellow]Monitorización en tiempo real aún no implementada[/yellow]",
+ "[yellow]Refresh completed with warnings[/yellow]": "[yellow]Actualización completada con advertencias[/yellow]",
+ "[yellow]Resume data validation found issues:[/yellow]": "[yellow]La validación de datos de reanudación encontró problemas:[/yellow]",
+ "[yellow]Rich not available, starting fresh download[/yellow]": "[yellow]Rich no disponible; iniciando descarga nueva[/yellow]",
+ "[yellow]Rule not found: {ip_range}[/yellow]": "[yellow]Regla no encontrada: {ip_range}[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended). Configuration saved to {config_file}[/yellow]": "[yellow]Verificación de certificado SSL desactivada (no recomendado). Configuración guardada en {config_file}[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended, configuration not persisted - no config file)[/yellow]": "[yellow]Verificación SSL desactivada (no recomendado, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended, skipped write in test mode)[/yellow]": "[yellow]Verificación SSL desactivada (no recomendado, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL certificate verification enabled (configuration not persisted - no config file)[/yellow]": "[yellow]Verificación SSL habilitada (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL certificate verification enabled (skipped write in test mode)[/yellow]": "[yellow]Verificación SSL habilitada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for peers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para pares desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for peers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL para pares desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for peers enabled (experimental, configuration not persisted - no config file)[/yellow]": "[yellow]SSL para pares habilitado (experimental, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for peers enabled (experimental, skipped write in test mode)[/yellow]": "[yellow]SSL para pares habilitado (experimental, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for trackers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para trackers desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for trackers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL para trackers desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for trackers enabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para trackers habilitado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for trackers enabled (skipped write in test mode)[/yellow]": "[yellow]SSL para trackers habilitado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Select failed: {error}[/yellow]": "[yellow]Error al seleccionar: {error}[/yellow]",
+ "[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]": "[yellow]Use --download-limit/--upload-limit para límites globales; por par vía configuración[/yellow]",
+ "[yellow]Starting fresh download[/yellow]": "[yellow]Iniciando descarga nueva[/yellow]",
+ "[yellow]TLS protocol version set to {version} (configuration not persisted - no config file)[/yellow]": "[yellow]Versión TLS en {version} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]TLS protocol version set to {version} (skipped write in test mode)[/yellow]": "[yellow]Versión TLS en {version} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]The daemon process crashed during initialization.[/yellow]": "[yellow]El proceso del demonio falló durante la inicialización.[/yellow]",
+ "[yellow]The daemon process exited unexpectedly. Check daemon logs for error details.[/yellow]": "[yellow]El proceso del demonio salió de forma inesperada. Revise los registros del demonio.[/yellow]",
+ "[yellow]This usually indicates a configuration error, missing dependency, or initialization failure.[/yellow]": "[yellow]Suele indicar error de configuración, dependencia faltante o fallo de inicialización.[/yellow]",
+ "[yellow]Timeout waiting for daemon (last status: {last_status})[/yellow]": "[yellow]Tiempo de espera del demonio agotado (último estado: {last_status})[/yellow]",
+ "[yellow]To see errors in the terminal, run:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]": "[yellow]Para ver errores en la terminal, ejecute:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]",
+ "[yellow]Toggle encryption via --enable-encryption/--disable-encryption on download/magnet[/yellow]": "[yellow]Active el cifrado con --enable-encryption/--disable-encryption en download/magnet[/yellow]",
+ "[yellow]Torrent not found in queue[/yellow]": "[yellow]Torrent no encontrado en la cola[/yellow]",
+ "[yellow]Torrent not found or not active. Resume data will be automatically saved when torrent completes.[/yellow]": "[yellow]Torrent no encontrado o inactivo. Los datos de reanudación se guardarán al completar el torrent.[/yellow]",
+ "[yellow]Torrent not found[/yellow]": "[yellow]Torrent no encontrado[/yellow]",
+ "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load or --save[/yellow]": "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load o --save[/yellow]",
+ "[yellow]Use -v flag for more details or try --foreground to see error output[/yellow]": "[yellow]Use -v para más detalles o --foreground para ver el error[/yellow]",
+ "[yellow]Warning: Checkpoint save failed[/yellow]": "[yellow]Advertencia: falló al guardar el punto de control[/yellow]",
+ "[yellow]Warning: Configuration changes require daemon restart, but restart was skipped.[/yellow]": "[yellow]Advertencia: los cambios requieren reiniciar el demonio, pero se omitió el reinicio.[/yellow]",
+ "[yellow]Warning: Daemon is running. Diagnostics will test local session which may cause port conflicts.[/yellow]\n[dim]Consider stopping the daemon first: 'btbt daemon exit'[/dim]\n": "[yellow]Advertencia: el demonio está en ejecución. El diagnóstico usará sesión local y puede haber conflictos de puerto.[/yellow]\n[dim]Considere detener el demonio primero: «btbt daemon exit»[/dim]\n",
+ "[yellow]Warning: Error saving checkpoint: {error}[/yellow]": "[yellow]Advertencia: error al guardar punto de control: {error}[/yellow]",
+ "[yellow]Warning: Error stopping session: {e}[/yellow]": "[yellow]Advertencia: error al detener la sesión: {e}[/yellow]",
+ "[yellow]Warning: Failed to save checkpoint: {error}[/yellow]": "[yellow]Advertencia: no se pudo guardar el punto de control: {error}[/yellow]",
+ "[yellow]Warning: Failed to select files: {error}[/yellow]": "[yellow]Advertencia: no se pudieron seleccionar archivos: {error}[/yellow]",
+ "[yellow]Warning: Failed to set queue priority: {error}[/yellow]": "[yellow]Advertencia: no se pudo establecer la prioridad en cola: {error}[/yellow]",
+ "[yellow]Warning: IPC client not available[/yellow]": "[yellow]Advertencia: cliente IPC no disponible[/yellow]",
+ "[yellow]Warning: SSL certificate verification is disabled while SSL is used in strict mode[/yellow]": "[yellow]Advertencia: la verificación SSL está desactivada mientras SSL se usa en modo estricto[/yellow]",
+ "[yellow]Warning: V1 torrent generation is not yet implemented.[/yellow]": "[yellow]Advertencia: la generación de torrent v1 aún no está implementada.[/yellow]",
+ "[yellow]Warning: certificate verification is disabled while SSL is in strict posture[/yellow]": "[yellow]Advertencia: verificación de certificado desactivada con SSL en postura estricta[/yellow]",
+ "[yellow]Would delete {count} checkpoints older than {days} days:[/yellow]": "[yellow]Se eliminarían {count} puntos de control de más de {days} días:[/yellow]",
+ "[yellow]{key} is not set[/yellow]": "[yellow]{key} no está definido[/yellow]",
+ "[yellow]⚠[/yellow] Could not save daemon config to config file: {e}": "[yellow]⚠[/yellow] No se pudo guardar la configuración del demonio: {e}",
+ "[yellow]⚠[/yellow] Daemon process started (PID {pid}) but may not be fully ready yet": "[yellow]⚠[/yellow] Proceso del demonio iniciado (PID {pid}) pero puede no estar listo aún",
+ "[yellow]⚠[/yellow] Daemon startup timeout after {timeout:.1f}s (last status: {last_status})": "[yellow]⚠[/yellow] Tiempo de espera de inicio del demonio tras {timeout:.1f}s (último estado: {last_status})",
+ "[yellow]⚠[/yellow] {errors} errors encountered": "[yellow]⚠[/yellow] Se encontraron {errors} errores",
+ "[yellow]✓[/yellow] Xet protocol disabled": "[yellow]✓[/yellow] Protocolo Xet desactivado",
+ "[yellow]✓[/yellow] uTP transport disabled": "[yellow]✓[/yellow] Transporte uTP desactivado",
+ "_get_executor() returned: executor=%s, is_daemon=%s": "_get_executor() devolvió: executor=%s, is_daemon=%s",
+ "aiortc not installed": "aiortc no instalado",
+ "disabled": "desactivado",
+ "enable_dht={value}": "enable_dht={value}",
+ "enable_pex={value}": "enable_pex={value}",
+ "enabled": "habilitado",
+ "failed": "fallido",
+ "fell": "bajó",
+ "http://tracker.example.com:8080/announce": "http://tracker.example.com:8080/announce",
+ "no": "no",
+ "none": "ninguno",
+ "not ready yet": "aún no listo",
+ "peers": "pares",
+ "pieces": "piezas",
+ "replace: file must be a full valid document; merge: deep-merge into existing target TOML then validate": "replace: el archivo debe ser un documento completo válido; merge: fusión profunda en el TOML de destino y validar",
+ "rose": "subió",
+ "succeeded": "correcto",
+ "tonic share requires the daemon. Start it with: btbt daemon start": "compartir tonic requiere el demonio. Inícielo con: btbt daemon start",
+ "uTP": "uTP",
+ "uTP (uTorrent Transport Protocol) Options:\n\nuTP provides reliable, ordered delivery over UDP with delay-based congestion control (BEP 29).\nUseful for better performance on networks with high latency or packet loss.": "uTP (protocolo de transporte uTorrent). Opciones:\n\nuTP ofrece entrega fiable y ordenada sobre UDP con control de congestión por retardo (BEP 29).\nÚtil en redes con alta latencia o pérdida de paquetes.",
+ "uTP Configuration": "Configuración uTP",
+ "uTP config": "Config. uTP",
+ "uTP configuration reset to defaults via CLI": "Configuración uTP restablecida a valores predeterminados por CLI",
+ "uTP configuration updated: %s = %s": "Configuración uTP actualizada: %s = %s",
+ "uTP transport disabled via CLI": "Transporte uTP desactivado por CLI",
+ "uTP transport enabled": "Transporte uTP habilitado",
+ "uTP transport enabled via CLI": "Transporte uTP habilitado por CLI",
+ "unknown": "desconocido",
+ "unlimited": "ilimitado",
+ "yes": "sí",
+ "{connection} Torrents: {torrents} Active: {active} Paused: {paused} Seeding: {seeding} D: {download}B/s U: {upload}B/s": "{connection} Torrents: {torrents} Activos: {active} Pausados: {paused} Semilla: {seeding} D: {download}B/s S: {upload}B/s",
+ "{graph_tab_id} - Data provider configuration error": "{graph_tab_id} — error de configuración del proveedor de datos",
+ "{graph_tab_id} - Data provider not available": "{graph_tab_id} — proveedor de datos no disponible",
+ "{hours:.1f}h ago": "hace {hours:.1f} h",
+ "{key} = {value}": "{key} = {value}",
+ "{key}: {value}": "{key}: {value}",
+ "{minutes:.0f}m ago": "hace {minutes:.0f} min",
+ "{msg}\n\nPID file path: {path}": "{msg}\n\nRuta del archivo PID: {path}",
+ "{seconds:.0f}s ago": "hace {seconds:.0f} s",
+ "{sub_tab} configuration - Coming soon": "Configuración de {sub_tab} — próximamente",
+ "{sub_tab} content for torrent {hash}... - Coming soon": "Contenido de {sub_tab} para torrent {hash}… — próximamente",
+ "{type} Configuration": "Configuración {type}",
+ "↑ Rate": "↑ Tasa",
+ "↑ Speed": "↑ Velocidad",
+ "↓ Rate": "↓ Tasa",
+ "↓ Speed": "↓ Velocidad",
+ "≥ 80% available": "≥ 80 % disponible",
+ "⏸ Pause": "⏸ Pausa",
+ "▶ Resume": "▶ Reanudar",
+ "⚠️ Daemon restart required to apply changes.\n": "⚠️ Hay que reiniciar el demonio para aplicar los cambios.\n",
+ "✓ Configuration is valid": "✓ La configuración es válida",
+ "✓ No system compatibility warnings": "✓ Sin advertencias de compatibilidad del sistema",
+ "✓ Verify": "✓ Verificar",
+ "✗ Configuration validation failed: {e}": "✗ Validación de configuración fallida: {e}",
+ "📊 Refresh PEX": "📊 Actualizar PEX",
+ "📥 Export State": "📥 Exportar estado",
+ "🔄 Reannounce": "🔄 Reanunciar",
+ "🔍 Rehash": "🔍 Rehash",
+ "🗑 Remove": "🗑 Quitar"
+}
diff --git a/ccbt/i18n/locale_data/es_gap_bulk.json b/ccbt/i18n/locale_data/es_gap_bulk.json
new file mode 100644
index 00000000..8fd14561
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_gap_bulk.json
@@ -0,0 +1,1135 @@
+{
+ "Enabled (Dependency Missing)": "Habilitado (falta dependencia)",
+ "Enabled (Not Started)": "Habilitado (no iniciado)",
+ "Encrypt backup with generated key": "Cifrar la copia de seguridad con clave generada",
+ "Encrypting backup...": "Cifrando copia de seguridad…",
+ "Endgame duplicate requests": "Solicitudes duplicadas en fase final",
+ "Endgame threshold (0..1)": "Umbral de fase final (0..1)",
+ "Enter Tracker URL": "Introduzca la URL del tracker",
+ "Enter path...": "Introduzca la ruta…",
+ "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.": "Introduzca el directorio donde deben descargarse los archivos:\n\nDéjelo vacío para usar el directorio actual.",
+ "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...": "Introduzca la ruta de un archivo .torrent o un enlace magnet:\n\nEjemplos:\n /ruta/al/archivo.torrent\n magnet:?xt=urn:btih:...",
+ "Enter torrent file path or magnet link": "Introduzca la ruta del archivo torrent o el enlace magnet",
+ "Enter torrent file path or magnet link:": "Introduzca la ruta del archivo torrent o el enlace magnet:",
+ "Error": "Error",
+ "Error: {error}": "Error: {error}",
+ "Errors": "Errores",
+ "Estimated Read Speed": "Velocidad de lectura estimada",
+ "Estimated Write Speed": "Velocidad de escritura estimada",
+ "Events": "Eventos",
+ "Eviction rate: {rate:.2f} /sec": "Tasa de expulsión: {rate:.2f} /s",
+ "Exceeded maximum wait time (%.1fs) for daemon readiness": "Se superó el tiempo máximo de espera (%.1fs) para la disponibilidad del demonio",
+ "Excellent": "Excelente",
+ "Exists": "Existe",
+ "Expected info hash (hex)": "Hash de información esperado (hexadecimal)",
+ "Expected type: {type_name}": "Tipo esperado: {type_name}",
+ "Export complete": "Exportación completada",
+ "Exporting checkpoint...": "Exportando punto de control…",
+ "Failed Requests": "Solicitudes fallidas",
+ "Fair": "Regular",
+ "Fetching Metadata...": "Obteniendo metadatos…",
+ "Fetching file list for selection. This may take a moment.": "Obteniendo la lista de archivos para la selección. Puede tardar un momento.",
+ "Field": "Campo",
+ "File Browser": "Navegador de archivos",
+ "File Browser - Data provider or executor not available": "Explorador de archivos: proveedor de datos o ejecutor no disponible",
+ "File Browser - Error: {error}": "Explorador de archivos: error: {error}",
+ "File Browser - Select files to create torrents": "Explorador de archivos: seleccione archivos para crear torrents",
+ "File Explorer": "Explorador de archivos",
+ "File must have .torrent extension: %s": "El archivo debe tener extensión .torrent: %s",
+ "File not found: %s": "Archivo no encontrado: %s",
+ "File {number}": "Archivo {number}",
+ "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}": "Archivo: {name}\nPuerto: {port}\nBytes servidos: {bytes_served}\nClientes: {clients}\nÚltimo rango: {start} - {end}\nBytes legibles: {available}\nÚltimo error: {error}",
+ "Files in torrent {hash}...": "Archivos en el torrent {hash}…",
+ "Files: {count}": "Archivos: {count}",
+ "Filter update failed": "Error al actualizar el filtro",
+ "Folder not found: {folder}": "Carpeta no encontrada: {folder}",
+ "Folder: {name}": "Carpeta: {name}",
+ "Force Announce": "Forzar anuncio",
+ "Force kill without graceful shutdown": "Forzar cierre sin apagado ordenado",
+ "Found {count} potential issues": "Se encontraron {count} posibles problemas",
+ "Full Path": "Ruta completa",
+ "Full configuration editing requires navigating to the Global Config screen": "La edición completa de la configuración requiere ir a la pantalla de configuración global",
+ "General": "General",
+ "General configuration - Data provider/Executor not available": "Configuración general: proveedor de datos o ejecutor no disponible",
+ "Generate new API key": "Generar nueva clave de API",
+ "Generated new API key for daemon": "Nueva clave de API generada para el demonio",
+ "Generating {format} torrent...": "Generando torrent {format}…",
+ "GitHub Dark": "GitHub oscuro",
+ "Global": "Global",
+ "Global Configuration": "Configuración global",
+ "Global Connected Peers": "Pares conectados (global)",
+ "Global KPIs": "KPI globales",
+ "Global KPIs data is unavailable in the current mode.": "Los datos de KPI globales no están disponibles en este modo.",
+ "Global Key Performance Indicators": "Indicadores clave de rendimiento globales",
+ "Global Torrent Metrics": "Métricas globales de torrent",
+ "Global config": "Configuración global",
+ "Global download limit (KiB/s)": "Límite global de descarga (KiB/s)",
+ "Global upload limit (KiB/s)": "Límite global de subida (KiB/s)",
+ "Good": "Bueno",
+ "Graceful shutdown timeout, forcing stop": "Tiempo de apagado agotado; forzando detención",
+ "Graphs": "Gráficos",
+ "Gruvbox": "Gruvbox",
+ "HTTP error checking daemon status at %s: %s (status %d)": "Error HTTP al comprobar el estado del demonio en %s: %s (estado %d)",
+ "Hash Chunk Size": "Tamaño de trozo para hash",
+ "Hash verification workers": "Hilos de verificación de trozos",
+ "Health": "Salud",
+ "Help screen": "Pantalla de ayuda",
+ "High": "Alto",
+ "Historical trends": "Tendencias históricas",
+ "Host for web interface": "Host para la interfaz web",
+ "IP Address": "Dirección IP",
+ "IP filter not available": "Filtro IP no disponible",
+ "IP:Port": "IP:Puerto",
+ "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)": "IPCClient.get_daemon_pid: comprobando pid_file=%s (home_dir=%s, existe=%s)",
+ "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.": "Opciones del protocolo IPFS:\n\nIPFS permite almacenamiento direccionado por contenido y uso compartido entre pares.\nTras la descarga se puede acceder al contenido mediante el CID de IPFS.",
+ "IPFS management": "Gestión de IPFS",
+ "Idle": "Ocioso",
+ "Inactive": "Inactivo",
+ "Include effective runtime value from loaded config (file + env)": "Incluir el valor en tiempo de ejecución efectivo de la configuración cargada (archivo + entorno)",
+ "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)": "Aumentar verbosidad (-v: detallado, -vv: depuración, -vvv: trazas)",
+ "Index": "Índice",
+ "Info": "Información",
+ "Info Hashes": "Hashes de información",
+ "Info hash copied to clipboard": "Hash de información copiado al portapapeles",
+ "Info hash: {hash}": "Hash de información: {hash}",
+ "Initial Rate": "Tasa inicial",
+ "Initial send rate": "Tasa de envío inicial",
+ "Invalid IP address: {error}": "Dirección IP no válida: {error}",
+ "Invalid IP range: {ip_range}": "Rango IP no válido: {ip_range}",
+ "Invalid configuration after merge: {e}": "Configuración no válida tras la fusión: {e}",
+ "Invalid configuration: top-level must be an object": "Configuración no válida: el nivel superior debe ser un objeto",
+ "Invalid configuration: {e}": "Configuración no válida: {e}",
+ "Invalid info hash format": "Formato de hash de información no válido",
+ "Invalid info hash format: %s": "Formato de hash de información no válido: %s",
+ "Invalid info hash format: {hash}": "Formato de hash de información no válido: {hash}",
+ "Invalid info hash length in magnet link": "Longitud de hash no válida en el enlace magnet",
+ "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu": "Configuración regional '{current_locale}' no válida. Se usará 'en'. Disponibles: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu",
+ "Invalid magnet link - missing 'xt=urn:btih:' parameter": "Enlace magnet no válido: falta el parámetro 'xt=urn:btih:'",
+ "Invalid magnet link format": "Formato de enlace magnet no válido",
+ "Invalid magnet link format - must start with 'magnet:?'": "Formato de enlace magnet no válido: debe comenzar por 'magnet:?'",
+ "Invalid peer selection": "Selección de par no válida",
+ "Invalid profile '{name}': {errors}": "Perfil '{name}' no válido: {errors}",
+ "Invalid template '{name}': {errors}": "Plantilla '{name}' no válida: {errors}",
+ "Invalid tracker URL format. Must start with http://, https://, or udp://": "Formato de URL de tracker no válido. Debe comenzar por http://, https:// o udp://",
+ "Invalid tracker selection": "Selección de tracker no válida",
+ "Key Bindings": "Atajos de teclado",
+ "Language": "Idioma",
+ "Last Error": "Último error",
+ "Last Update": "Última actualización",
+ "Last sample {age}": "Última muestra {age}",
+ "Latency": "Latencia",
+ "Light": "Claro",
+ "Light Mode": "Modo claro",
+ "List available locales": "Listar configuraciones regionales disponibles",
+ "Listen interface": "Interfaz de escucha",
+ "Listen port": "Puerto de escucha",
+ "Loading configuration...": "Cargando configuración…",
+ "Loading file list…": "Cargando lista de archivos…",
+ "Loading peer metrics...": "Cargando métricas de pares…",
+ "Loading piece selection metrics...": "Cargando métricas de selección de piezas…",
+ "Loading swarm timeline...": "Cargando línea temporal del enjambre…",
+ "Loading torrent information...": "Cargando información del torrent…",
+ "Local Node Information": "Información del nodo local",
+ "Low": "Bajo",
+ "MMap cache size (MB)": "Tamaño de caché MMap (MB)",
+ "MTU": "MTU",
+ "Magnet command: PID file check - exists=%s, path=%s": "Comando magnet: comprobación de archivo PID: existe=%s, ruta=%s",
+ "Magnet link must contain 'xt=urn:btih:' parameter": "El enlace magnet debe contener el parámetro 'xt=urn:btih:'",
+ "Magnet link must start with 'magnet:?'": "El enlace magnet debe comenzar por 'magnet:?'",
+ "Max Rate": "Tasa máxima",
+ "Max Retransmits": "Retransmisiones máx.",
+ "Max Window Size": "Tamaño máx. de ventana",
+ "Maximum": "Máximo",
+ "Maximum UDP packet size": "Tamaño máx. de paquete UDP",
+ "Maximum block size (KiB)": "Tamaño máx. de bloque (KiB)",
+ "Maximum download rate for this torrent": "Tasa máx. de descarga para este torrent",
+ "Maximum global peers": "Máximo de pares globales",
+ "Maximum peers per torrent": "Máximo de pares por torrent",
+ "Maximum receive window size": "Tamaño máx. de ventana de recepción",
+ "Maximum retransmission attempts": "Intentos máx. de retransmisión",
+ "Maximum send rate": "Tasa máx. de envío",
+ "Maximum upload rate for this torrent": "Tasa máx. de subida para este torrent",
+ "Media": "Multimedia",
+ "Media Playback": "Reproducción multimedia",
+ "Media stream started.": "Transmisión multimedia iniciada.",
+ "Media stream stopped.": "Transmisión multimedia detenida.",
+ "Medium": "Medio",
+ "Memory": "Memoria",
+ "Metadata is loading. File selection will appear when available.": "Cargando metadatos. La selección de archivos aparecerá cuando esté disponible.",
+ "Metrics explorer": "Explorador de métricas",
+ "Metrics interval (s)": "Intervalo de métricas (s)",
+ "Metrics interval: {interval}s": "Intervalo de métricas: {interval}s",
+ "Metrics port": "Puerto de métricas",
+ "Migrating checkpoint format from {from_fmt} to {to_fmt}...": "Migrando formato de punto de control de {from_fmt} a {to_fmt}…",
+ "Migration complete": "Migración completada",
+ "Min Rate": "Tasa mínima",
+ "Minimum block size (KiB)": "Tamaño mín. de bloque (KiB)",
+ "Minimum send rate": "Tasa mín. de envío",
+ "Mode": "Modo",
+ "Model '{model}' not found in Config": "Modelo '{model}' no encontrado en Config",
+ "Modified": "Modificado",
+ "Monitoring": "Monitorización",
+ "Monokai": "Monokai",
+ "N/A": "N/D",
+ "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.": "Opciones de NAT traversal:\n\nEl NAT traversal (NAT-PMP/UPnP) asigna puertos en su router automáticamente.\nPermite que los pares se conecten directamente y mejora la velocidad de descarga.",
+ "NAT management": "Gestión NAT",
+ "Name: {name}": "Nombre: {name}",
+ "Navigation": "Navegación",
+ "Navigation menu": "Menú de navegación",
+ "Network Configuration": "Configuración de red",
+ "Network Optimization Recommendations": "Recomendaciones de optimización de red",
+ "Network Performance": "Rendimiento de red",
+ "Network configuration (connections, timeouts, rate limits)": "Configuración de red (conexiones, tiempos de espera, límites de velocidad)",
+ "Network configuration - Data provider/Executor not available": "Configuración de red: proveedor de datos o ejecutor no disponible",
+ "Network quality": "Calidad de red",
+ "Network quality - Error: {error}": "Calidad de red: error: {error}",
+ "Never": "Nunca",
+ "Next": "Siguiente",
+ "Next Step": "Paso siguiente",
+ "No DHT metrics per torrent yet.": "Aún no hay métricas DHT por torrent.",
+ "No PID file found, checking for daemon via _get_executor()": "No se encontró archivo PID; comprobando demonio mediante _get_executor()",
+ "No access": "Sin acceso",
+ "No active stream to stop.": "No hay transmisión activa que detener.",
+ "No availability data": "Sin datos de disponibilidad",
+ "No checkpoint found": "No se encontró punto de control",
+ "No commands available": "No hay comandos disponibles",
+ "No configuration file to backup": "No hay archivo de configuración que respaldar",
+ "No daemon PID file found - daemon is not running": "No se encontró archivo PID del demonio; el demonio no está en ejecución",
+ "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s": "No se detectó demonio (no existe el archivo PID); creando sesión local. Ruta del PID: %s",
+ "No file selected": "Ningún archivo seleccionado",
+ "No files to deselect": "No hay archivos que deseleccionar",
+ "No files to select": "No hay archivos que seleccionar",
+ "No locales directory found": "No se encontró el directorio de configuraciones regionales",
+ "No magnet URI provided": "No se proporcionó URI magnet",
+ "No magnet URI provided for add_magnet operation.": "No se proporcionó URI magnet para la operación add_magnet.",
+ "No metrics available": "No hay métricas disponibles",
+ "No peer quality data available": "No hay datos de calidad de pares",
+ "No peer selected": "Ningún par seleccionado",
+ "No peers available": "No hay pares disponibles",
+ "No per-torrent data available": "No hay datos por torrent",
+ "No pieces": "Sin piezas",
+ "No playable files": "Sin archivos reproducibles",
+ "No playable media files were detected for this torrent.": "No se detectaron archivos multimedia reproducibles para este torrent.",
+ "No recent security events.": "No hay eventos de seguridad recientes.",
+ "No section selected for editing": "Ninguna sección seleccionada para editar",
+ "No significant events detected.": "No se detectaron eventos significativos.",
+ "No swarm activity captured for the selected window.": "No se capturó actividad del enjambre en la ventana seleccionada.",
+ "No swarm samples": "Sin muestras del enjambre",
+ "No torrent data loaded. Please go back to step 1.": "No se cargaron datos del torrent. Vuelva al paso 1.",
+ "No torrent path or magnet provided": "No se proporcionó ruta de torrent ni magnet",
+ "No torrent path or magnet provided for add_torrent operation.": "No se proporcionó ruta ni magnet para la operación add_torrent.",
+ "No torrents with DHT activity yet.": "Aún no hay torrents con actividad DHT.",
+ "No torrents yet. Use 'add' to start downloading.": "Aún no hay torrents. Use 'add' para empezar a descargar.",
+ "No tracker selected": "Ningún tracker seleccionado",
+ "No trackers found": "No se encontraron trackers",
+ "Node ID": "ID de nodo",
+ "Node Information": "Información del nodo",
+ "Node information not available.": "Información del nodo no disponible.",
+ "Nodes/Q": "Nodos/cola",
+ "Non-Empty Buckets": "Cubetas no vacías",
+ "Nord": "Nord",
+ "Normal": "Normal",
+ "Not enabled": "No habilitado",
+ "Not enabled in configuration": "No habilitado en la configuración",
+ "Not initialized": "No inicializado",
+ "Note": "Nota",
+ "Number of pieces to verify for integrity (0 = disable)": "Número de piezas a verificar por integridad (0 = desactivar)",
+ "OK (dry-run — configuration is valid)": "OK (simulación — la configuración es válida)",
+ "OK (dry-run — merged configuration is valid)": "OK (simulación — la configuración fusionada es válida)",
+ "One Dark": "One Dark",
+ "Only options in this top-level section (e.g. network)": "Solo opciones en esta sección de nivel superior (p. ej. red)",
+ "Only paths starting with this prefix": "Solo rutas que comiencen con este prefijo",
+ "Open File": "Abrir archivo",
+ "Open Folder": "Abrir carpeta",
+ "Open in VLC": "Abrir en VLC",
+ "Opened folder: {path}": "Carpeta abierta: {path}",
+ "Opened stream in external player via {method}.": "Transmisión abierta en reproductor externo mediante {method}.",
+ "Optimistic unchoke interval (s)": "Intervalo de optimistic unchoke (s)",
+ "Option": "Opción",
+ "Others can join with: ccbt tonic sync \"{link}\" --output ": "Otros pueden unirse con: ccbt tonic sync \"{link}\" --output ",
+ "Output Directory": "Directorio de salida",
+ "Output directory": "Directorio de salida",
+ "Output directory (default: current directory)": "Directorio de salida (predeterminado: directorio actual)",
+ "Output directory not available": "Directorio de salida no disponible",
+ "Output file path": "Ruta del archivo de salida",
+ "Output format for the option catalog": "Formato de salida del catálogo de opciones",
+ "Overall Efficiency": "Eficiencia general",
+ "Overall Health": "Salud general",
+ "Override IPC server port": "Sobrescribir puerto del servidor IPC",
+ "PEX interval (s)": "Intervalo PEX (s)",
+ "PEX refresh failed: {error}": "Error al actualizar PEX: {error}",
+ "PEX refresh requested": "Actualización de PEX solicitada",
+ "PEX: Failed": "PEX: fallido",
+ "PID file contains invalid PID: %d, removing": "El archivo PID contiene un PID no válido: %d; eliminando",
+ "PID file contains invalid data: %r, removing": "El archivo PID contiene datos no válidos: %r; eliminando",
+ "PID file is empty, removing": "El archivo PID está vacío; eliminando",
+ "Parsing files and building file tree...": "Analizando archivos y construyendo el árbol...",
+ "Parsing files and building hybrid metadata...": "Analizando archivos y construyendo metadatos híbridos...",
+ "Patch file format (auto: infer from extension or try JSON then TOML)": "Formato de parche (auto: inferir por extensión o probar JSON y luego TOML)",
+ "Patch must be a JSON/TOML object at the top level": "El parche debe ser un objeto JSON/TOML en el nivel superior",
+ "Path": "Ruta",
+ "Path does not exist": "La ruta no existe",
+ "Path is not a file: %s": "La ruta no es un archivo: %s",
+ "Path or magnet://...": "Ruta o magnet://...",
+ "Path to config file": "Ruta del archivo de configuración",
+ "Pause failed: {error}": "Error al pausar: {error}",
+ "Pause torrent": "Pausar torrent",
+ "Paused": "En pausa",
+ "Paused {info_hash}…": "Pausado {info_hash}…",
+ "Peer": "Par",
+ "Peer Details": "Detalles del par",
+ "Peer Distribution": "Distribución de pares",
+ "Peer Efficiency": "Eficiencia de pares",
+ "Peer Quality": "Calidad del par",
+ "Peer Quality Distribution": "Distribución de calidad de pares",
+ "Peer Selection": "Selección de pares",
+ "Peer banning not yet implemented. Selected peer: {ip}:{port}": "Vetado de pares aún no implementado. Par seleccionado: {ip}:{port}",
+ "Peer distribution - Error: {error}": "Distribución de pares — error: {error}",
+ "Peer not found": "Par no encontrado",
+ "Peer quality - Error: {error}": "Calidad de pares — error: {error}",
+ "Peer quality data is unavailable in the current mode.": "Datos de calidad de pares no disponibles en este modo.",
+ "Peer timeout (s)": "Tiempo de espera del par (s)",
+ "Peer {ip}:{port} banned": "Par {ip}:{port} vetado",
+ "Peers Found": "Pares encontrados",
+ "Peers/Q": "Pares/cola",
+ "Per-Peer": "Por par",
+ "Per-Peer tab - Data provider or executor not available": "Pestaña por par: proveedor de datos o ejecutor no disponible",
+ "Per-Torrent": "Por torrent",
+ "Per-Torrent Config: {hash}...": "Config. por torrent: {hash}...",
+ "Per-Torrent Configuration": "Configuración por torrent",
+ "Per-Torrent Configuration: {name}": "Configuración por torrent: {name}",
+ "Per-Torrent Quality Summary": "Resumen de calidad por torrent",
+ "Per-Torrent tab - Data provider or executor not available": "Pestaña por torrent: proveedor de datos o ejecutor no disponible",
+ "Per-torrent DHT": "DHT por torrent",
+ "Per-torrent configuration - Data provider/Executor or torrent not available": "Configuración por torrent: proveedor de datos, ejecutor o torrent no disponible",
+ "Per-torrent configuration saved successfully": "Configuración por torrent guardada correctamente",
+ "Percentage": "Porcentaje",
+ "Performance metrics": "Métricas de rendimiento",
+ "Performance metrics - Error: {error}": "Métricas de rendimiento — error: {error}",
+ "Permission denied": "Permiso denegado",
+ "Piece Selection Strategy": "Estrategia de selección de piezas",
+ "Piece selection metrics are not available yet for this torrent.": "Las métricas de selección de piezas aún no están disponibles para este torrent.",
+ "Piece selection metrics are unavailable in the current mode.": "Métricas de selección de piezas no disponibles en este modo.",
+ "Pieces Received": "Piezas recibidas",
+ "Pieces Served": "Piezas servidas",
+ "Pin Content in IPFS:": "Fijar contenido en IPFS:",
+ "Pipeline Rejections": "Rechazos de la canalización",
+ "Pipeline Utilization": "Utilización de la canalización",
+ "Please enter a torrent path or magnet link": "Introduzca la ruta del torrent o el enlace magnet",
+ "Please fix parse errors before saving": "Corrija errores de análisis antes de guardar",
+ "Please fix validation errors before saving": "Corrija errores de validación antes de guardar",
+ "Please select a torrent first": "Seleccione primero un torrent",
+ "Poor": "Deficiente",
+ "Port for web interface": "Puerto para la interfaz web",
+ "Port: {port}, STUN: {stun_count} server(s)": "Puerto: {port}, STUN: {stun_count} servidor(es)",
+ "Prefer Protocol v2 when available": "Preferir protocolo v2 cuando esté disponible",
+ "Prefer over TCP": "Preferir sobre TCP",
+ "Prefer uTP when both TCP and uTP are available": "Preferir uTP cuando TCP y uTP estén disponibles",
+ "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s": "Preferir v2: {prefer_v2} | Híbrido: {hybrid} | Tiempo de espera: {timeout}s",
+ "Press Ctrl+C to stop the daemon": "Pulse Ctrl+C para detener el demonio",
+ "Press Enter to configure this section": "Pulse Intro para configurar esta sección",
+ "Previous": "Anterior",
+ "Previous Step": "Paso anterior",
+ "Prioritize first piece": "Priorizar la primera pieza",
+ "Prioritize last piece": "Priorizar la última pieza",
+ "Prioritized Pieces": "Piezas priorizadas",
+ "Priority (0 = normal, 1 = high, -1 = low):": "Prioridad (0 = normal, 1 = alta, -1 = baja):",
+ "Priority level": "Nivel de prioridad",
+ "Profile '{name}' not found": "Perfil '{name}' no encontrado",
+ "Profile applied to {path}": "Perfil aplicado en {path}",
+ "Profile config written to {path}": "Configuración de perfil escrita en {path}",
+ "Profile: {name}": "Perfil: {name}",
+ "Protocol v2 (BEP 52)": "Protocolo v2 (BEP 52)",
+ "Protocols (Ctrl+)": "Protocolos (Ctrl+)",
+ "Provide a VALUE argument or use --value=... for values with spaces or JSON": "Proporcione un argumento VALUE o use --value=... para valores con espacios o JSON",
+ "Proxy config": "Configuración del proxy",
+ "Public key must be 32 bytes (64 hex characters)": "La clave pública debe tener 32 bytes (64 caracteres hexadecimales)",
+ "PyYAML is required for YAML export": "PyYAML es necesario para exportar YAML",
+ "PyYAML is required for YAML import": "PyYAML es necesario para importar YAML",
+ "PyYAML is required for YAML patches": "PyYAML es necesario para parches YAML",
+ "Quality": "Calidad",
+ "Quality Distribution": "Distribución de calidad",
+ "Queries": "Consultas",
+ "Queries Received": "Consultas recibidas",
+ "Queries Sent": "Consultas enviadas",
+ "Quick Add Torrent": "Añadir torrent rápido",
+ "Quick Stats": "Estadísticas rápidas",
+ "Quick add torrent": "Añadir torrent rápido",
+ "RTT multiplier for retransmit timeout": "Multiplicador RTT para tiempo de espera de retransmisión",
+ "Rainbow": "Rainbow",
+ "Rate Limits (KiB/s)": "Límites de velocidad (KiB/s)",
+ "Rate limit configuration (global and per-torrent)": "Configuración de límites de velocidad (global y por torrent)",
+ "Rates": "Velocidades",
+ "Read IPC port %d from daemon config file (authoritative source)": "Leer puerto IPC %d del archivo de configuración del demonio (fuente autoritativa)",
+ "Recent Security Events ({count})": "Eventos de seguridad recientes ({count})",
+ "Recommended Settings": "Ajustes recomendados",
+ "Recommended Value": "Valor recomendado",
+ "Reconnect to peers from checkpoint": "Reconectar a pares desde el punto de control",
+ "Recovery & Pipeline Health": "Recuperación y salud de la canalización",
+ "Refresh": "Actualizar",
+ "Refresh PEX": "Actualizar PEX",
+ "Refresh tracker state from checkpoint": "Actualizar estado del tracker desde el punto de control",
+ "Rehash: Failed": "Rehash: fallido",
+ "Remaining chunks: {count}": "Fragmentos restantes: {count}",
+ "Remove": "Quitar",
+ "Remove Tracker": "Quitar tracker",
+ "Remove checkpoints older than N days": "Eliminar puntos de control más antiguos que N días",
+ "Remove failed: {error}": "Error al quitar: {error}",
+ "Remove tracker not yet implemented. Selected tracker: {url}": "Quitar tracker aún no implementado. Tracker seleccionado: {url}",
+ "Reputation Tracking": "Seguimiento de reputación",
+ "Request Efficiency": "Eficiencia de solicitudes",
+ "Request Latency": "Latencia de solicitud",
+ "Request Success": "Éxito de solicitudes",
+ "Request pipeline depth": "Profundidad de canalización de solicitudes",
+ "Required": "Obligatorio",
+ "Reset specific key only (otherwise resets all options)": "Restablecer solo una clave concreta (si no, restablece todas las opciones)",
+ "Resource": "Recurso",
+ "Resource Utilization": "Utilización de recursos",
+ "Responses Received": "Respuestas recibidas",
+ "Restart Required": "Reinicio necesario",
+ "Restart daemon now?": "¿Reiniciar el demonio ahora?",
+ "Restore complete": "Restauración completada",
+ "Restore failed": "Restauración fallida",
+ "Restoring checkpoint...": "Restaurando punto de control...",
+ "Resume failed: {error}": "Error al reanudar: {error}",
+ "Resume from checkpoint if available": "Reanudar desde el punto de control si está disponible",
+ "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.": "Reanudar desde el punto de control si está disponible:\n\nSi está habilitado, la descarga continuará desde el último punto de control.",
+ "Resume from checkpoint:": "Reanudar desde el punto de control:",
+ "Resume from checkpoint?": "¿Reanudar desde el punto de control?",
+ "Resume torrent": "Reanudar torrent",
+ "Resumed {info_hash}…": "Reanudado {info_hash}…",
+ "Resuming {name}": "Reanudando {name}",
+ "Retransmit Timeout Factor": "Factor de tiempo de espera de retransmisión",
+ "Routing Table": "Tabla de enrutamiento",
+ "Routing table statistics not available.": "Estadísticas de tabla de enrutamiento no disponibles.",
+ "Rule not found: {ip_range}": "Regla no encontrada: {ip_range}",
+ "Run additional system compatibility checks after model validation": "Ejecutar comprobaciones adicionales de compatibilidad del sistema tras la validación del modelo",
+ "Run in foreground (for debugging)": "Ejecutar en primer plano (para depuración)",
+ "SSL config": "Configuración SSL",
+ "Save Config": "Guardar configuración",
+ "Save Configuration": "Guardar configuración",
+ "Save checkpoint after reset": "Guardar punto de control tras el restablecimiento",
+ "Save checkpoint immediately after setting option": "Guardar punto de control inmediatamente tras establecer la opción",
+ "Saving torrent to {path}...": "Guardando torrent en {path}...",
+ "Scanning folder and calculating chunks...": "Escaneando carpeta y calculando trozos...",
+ "Schema written to {path}": "Esquema escrito en {path}",
+ "Scrape": "Scrape",
+ "Scrape Count": "Recuento de scrape",
+ "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.": "Opciones de scrape:\n\nEl scrape consulta estadísticas del tracker (seeders, leechers, descargas completadas).\nEl auto-scrape consultará el tracker automáticamente al añadir el torrent.",
+ "Scrape results": "Resultados de scrape",
+ "Scrape: Failed": "Scrape: fallido",
+ "Search torrents...": "Buscar torrents...",
+ "Section": "Sección",
+ "Section '{section}' is not a configuration section": "La sección '{section}' no es una sección de configuración",
+ "Section '{section}' not found": "Sección '{section}' no encontrada",
+ "Section: {section}": "Sección: {section}",
+ "Security": "Seguridad",
+ "Security Events": "Eventos de seguridad",
+ "Security Scan Status": "Estado del análisis de seguridad",
+ "Security Statistics": "Estadísticas de seguridad",
+ "Security configuration - Data provider/Executor not available": "Configuración de seguridad: proveedor de datos o ejecutor no disponible",
+ "Security manager not available. Security scanning requires local session mode.": "Gestor de seguridad no disponible. El análisis requiere modo de sesión local.",
+ "Security scan": "Análisis de seguridad",
+ "Security scan completed. No issues detected.": "Análisis de seguridad completado. No se detectaron problemas.",
+ "Security scan completed. {blocked} blocked connections, {events} security events detected.": "Análisis de seguridad completado. {blocked} conexiones bloqueadas, {events} eventos de seguridad detectados.",
+ "Security scan is not available when connected to daemon.": "El análisis de seguridad no está disponible al estar conectado al demonio.",
+ "Security settings (encryption, IP filtering, SSL)": "Ajustes de seguridad (cifrado, filtrado IP, SSL)",
+ "Seeding": "Siendo semilla",
+ "Seeds": "Semillas",
+ "Select": "Seleccionar",
+ "Select All": "Seleccionar todo",
+ "Select File Priority": "Seleccionar prioridad de archivo",
+ "Select Files to Download": "Seleccionar archivos para descargar",
+ "Select Language": "Seleccionar idioma",
+ "Select Priority": "Seleccionar prioridad",
+ "Select Section": "Seleccionar sección",
+ "Select Theme": "Seleccionar tema",
+ "Select a graph type to view": "Seleccione un tipo de gráfico",
+ "Select a section to configure": "Seleccione una sección para configurar",
+ "Select a section to configure. Press Enter to edit, Escape to go back.": "Seleccione una sección para configurar. Intro para editar, Escape para volver.",
+ "Select a sub-tab to view configuration options": "Seleccione una subpestaña para ver opciones de configuración",
+ "Select a sub-tab to view torrents": "Seleccione una subpestaña para ver torrents",
+ "Select a torrent and sub-tab to view details": "Seleccione un torrent y una subpestaña para ver detalles",
+ "Select a torrent insight tab": "Seleccione una pestaña de información del torrent",
+ "Select a workflow tab": "Seleccione una pestaña de flujo de trabajo",
+ "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all": "Seleccione archivos para descargar y establezca prioridades:\n Espacio: Alternar selección\n P: Cambiar prioridad\n A: Seleccionar todo\n D: Deseleccionar todo",
+ "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)": "Seleccionar archivos: [a]todos, [n]inguno o índices (p. ej. 0,2-5)",
+ "Select folder": "Seleccionar carpeta",
+ "Select playable file": "Seleccionar archivo reproducible",
+ "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.": "Seleccione la prioridad en cola para este torrent:\n\nLos torrents de mayor prioridad se iniciarán primero.",
+ "Select torrent...": "Seleccionar torrent...",
+ "Selected {count} file(s)": "Seleccionado(s) {count} archivo(s)",
+ "Set Limits": "Establecer límites",
+ "Set Priority": "Establecer prioridad",
+ "Set locale (e.g., 'en', 'es', 'fr')": "Establecer configuración regional (p. ej. 'en', 'es', 'fr')",
+ "Set priority to {priority} for file": "Establecer prioridad {priority} para el archivo",
+ "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.": "Establecer límites de velocidad para este torrent:\n\nIntroduzca 0 o déjelo vacío para ilimitado.",
+ "Setting": "Ajuste",
+ "Share Ratio": "Ratio de compartición",
+ "Share failed": "Error al compartir",
+ "Shared Peers": "Pares compartidos",
+ "Show checkpoints in specific format": "Mostrar puntos de control en un formato concreto",
+ "Show what would be deleted without actually deleting": "Mostrar qué se eliminaría sin eliminarlo realmente",
+ "Shutdown timeout in seconds": "Tiempo de espera de apagado en segundos",
+ "Size: {size}": "Tamaño: {size}",
+ "Skip & Continue": "Omitir y continuar",
+ "Skip waiting and select all files": "Omitir espera y seleccionar todos los archivos",
+ "Socket Optimizations": "Optimizaciones de socket",
+ "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.": "Prueba de conexión de socket a %s:%d fallida (resultado=%d). El puerto puede estar cerrado o un firewall lo bloquea. Se continuará con la comprobación HTTP de todas formas.",
+ "Socket manager not initialized": "Gestor de sockets no inicializado",
+ "Socket receive buffer (KiB)": "Búfer de recepción del socket (KiB)",
+ "Socket send buffer (KiB)": "Búfer de envío del socket (KiB)",
+ "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.": "La prueba de socket devolvió 10035 (WSAEWOULDBLOCK) en Windows para %s:%d. Puede ser un falso positivo; se continúa con la comprobación HTTP.",
+ "Solarized Dark": "Solarized oscuro",
+ "Solarized Light": "Solarized claro",
+ "Source path does not exist: %s": "La ruta de origen no existe: %s",
+ "Speed Category": "Categoría de velocidad",
+ "Speeds": "Velocidades",
+ "Start Stream": "Iniciar transmisión",
+ "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.": "Inicie una transmisión para exponer una URL HTTP en localhost para VLC u otro reproductor externo. El vídeo integrado en terminal no está contemplado.",
+ "Start daemon in background without waiting for completion (faster startup)": "Iniciar demonio en segundo plano sin esperar a que termine (inicio más rápido)",
+ "Start interactive mode": "Modo interactivo",
+ "Start the stream before opening VLC.": "Inicie la transmisión antes de abrir VLC.",
+ "Starting daemon...": "Iniciando demonio...",
+ "Starting file verification...": "Iniciando verificación de archivos...",
+ "State: stopped\nSelected file index: {index}": "Estado: detenido\nÍndice de archivo seleccionado: {index}",
+ "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}": "Estado: {state}\nURL: {url}\nPreparación del búfer: {buffer:.0%}",
+ "Step {current}/{total}: {steps}": "Paso {current}/{total}: {steps}",
+ "Stop Stream": "Detener transmisión",
+ "Stopped": "Detenido",
+ "Stopping daemon for restart...": "Deteniendo demonio para reiniciar...",
+ "Stopping daemon...": "Deteniendo demonio...",
+ "Stopping daemon... ({elapsed:.1f}s)": "Deteniendo demonio... ({elapsed:.1f}s)",
+ "Storage": "Almacenamiento",
+ "Storage Device Detection": "Detección de dispositivo de almacenamiento",
+ "Storage Type": "Tipo de almacenamiento",
+ "Storage configuration - Data provider/Executor not available": "Configuración de almacenamiento: proveedor de datos o ejecutor no disponible",
+ "Strategy": "Estrategia",
+ "Stuck Pieces Recovered": "Piezas atascadas recuperadas",
+ "Submit": "Enviar",
+ "Success": "Correcto",
+ "Successful Requests": "Solicitudes correctas",
+ "Summary": "Resumen",
+ "Supported MVP playback targets include common audio/video files.": "Los destinos de reproducción MVP admitidos incluyen archivos de audio/vídeo habituales.",
+ "Swarm Health": "Salud del enjambre",
+ "Swarm Timeline": "Línea temporal del enjambre",
+ "Swarm health - Error: {error}": "Salud del enjambre — error: {error}",
+ "Swarm timeline - Error: {error}": "Línea temporal del enjambre — error: {error}",
+ "System Efficiency": "Eficiencia del sistema",
+ "System recommendations:": "Recomendaciones del sistema:",
+ "System resources": "Recursos del sistema",
+ "System resources - Error: {error}": "Recursos del sistema — error: {error}",
+ "Template '{name}' not found": "Plantilla '{name}' no encontrada",
+ "Template applied to {path}": "Plantilla aplicada en {path}",
+ "Template config written to {path}": "Configuración de plantilla escrita en {path}",
+ "Template: {name}": "Plantilla: {name}",
+ "Templates: {templates}": "Plantillas: {templates}",
+ "Textual Dark": "Textual oscuro",
+ "Theme": "Tema",
+ "Theme: {theme}": "Tema: {theme}",
+ "This torrent has no files to select.": "Este torrent no tiene archivos para seleccionar.",
+ "This will modify your configuration file. Continue?": "Esto modificará su archivo de configuración. ¿Continuar?",
+ "Tier": "Nivel",
+ "Time": "Tiempo",
+ "Timeline": "Línea temporal",
+ "Timeline data is unavailable in the current mode.": "Datos de línea temporal no disponibles en este modo.",
+ "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Tiempo de espera al comprobar accesibilidad del demonio (intento %d/%d, transcurrido %.1fs), reintentando en %.1fs...",
+ "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)": "Tiempo de espera al comprobar accesibilidad del demonio tras %d intentos (transcurrido %.1fs)",
+ "Timeout checking daemon status at %s (daemon may be starting up or overloaded)": "Tiempo de espera al comprobar el estado del demonio en %s (el demonio puede estar iniciándose o sobrecargado)",
+ "Tip: full option catalog and file merge → ": "Sugerencia: catálogo completo de opciones y fusión de archivos → ",
+ "Toggle Dark/Light": "Alternar oscuro/claro",
+ "Tokyo Night": "Tokyo Night",
+ "Top 10 Peers by Quality": "Los 10 mejores pares por calidad",
+ "Top profile entries:": "Entradas principales del perfil:",
+ "Torrent": "Torrent",
+ "Torrent Control": "Control de torrent",
+ "Torrent Controls": "Controles de torrent",
+ "Torrent Controls - Data provider or executor not available": "Controles de torrent: proveedor de datos o ejecutor no disponible",
+ "Torrent Controls - Error: {error}": "Controles de torrent — error: {error}",
+ "Torrent File Explorer": "Explorador de archivos de torrent",
+ "Torrent Information": "Información del torrent",
+ "Torrent config": "Configuración del torrent",
+ "Torrent file is empty: %s": "El archivo torrent está vacío: %s",
+ "Torrent file not found: %s": "Archivo torrent no encontrado: %s",
+ "Torrent paused": "Torrent en pausa",
+ "Torrent priority": "Prioridad del torrent",
+ "Torrent removed": "Torrent eliminado",
+ "Torrent resumed": "Torrent reanudado",
+ "Torrent saved to {path}": "Torrent guardado en {path}",
+ "Torrents tab - Data provider or executor not available": "Pestaña Torrents: proveedor de datos o ejecutor no disponible",
+ "Torrents with DHT": "Torrents con DHT",
+ "Total Buckets": "Cubetas totales",
+ "Total Connections": "Conexiones totales",
+ "Total Downloaded": "Descargado total",
+ "Total Nodes": "Nodos totales",
+ "Total Peers": "Pares totales",
+ "Total Peers: {total} | Active Peers: {active}": "Pares totales: {total} | Pares activos: {active}",
+ "Total Queries": "Consultas totales",
+ "Total Requests": "Solicitudes totales",
+ "Total Size": "Tamaño total",
+ "Total Uploaded": "Subida total",
+ "Total chunks: {count}": "Trozos totales: {count}",
+ "Total queries": "Consultas totales",
+ "Tracker": "Tracker",
+ "Tracker Error": "Error de tracker",
+ "Tracker added: {url}": "Tracker añadido: {url}",
+ "Tracker announce interval (s)": "Intervalo de announce del tracker (s)",
+ "Tracker removed: {url}": "Tracker quitado: {url}",
+ "Tracker scrape interval (s)": "Intervalo de scrape del tracker (s)",
+ "Trackers": "Trackers",
+ "Tracking {count} torrent(s) across {minutes} minute window": "Siguiendo {count} torrent(s) en una ventana de {minutes} minuto(s)",
+ "Trend: {trend} ({delta:+.1f}pp)": "Tendencia: {trend} ({delta:+.1f}pp)",
+ "UI refresh interval: {interval}s": "Intervalo de actualización de la UI: {interval}s",
+ "URL": "URL",
+ "Unavailable": "No disponible",
+ "Unchoke interval (s)": "Intervalo de unchoke (s)",
+ "Unexpected error checking daemon status at %s: %s": "Error inesperado al comprobar el estado del demonio en %s: %s",
+ "Unknown error": "Error desconocido",
+ "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.": "Operación desconocida «{operation}» solicitada pero existe archivo PID del demonio. No debería ocurrir; repórtelo como error.",
+ "Unknown operation: %s": "Operación desconocida: %s",
+ "Unlimited": "Ilimitado",
+ "Up (B/s)": "Subida (B/s)",
+ "Updated at {time}": "Actualizado a las {time}",
+ "Updated config file with daemon configuration": "Archivo de configuración actualizado con la del demonio",
+ "Upload Limit": "Límite de subida",
+ "Upload Limit (KiB/s):": "Límite de subida (KiB/s):",
+ "Upload Rate": "Tasa de subida",
+ "Upload Rate Limit (bytes/sec, 0 = unlimited):": "Límite de tasa de subida (bytes/s, 0 = ilimitado):",
+ "Upload limit (KiB/s, 0 = unlimited)": "Límite de subida (KiB/s, 0 = ilimitado)",
+ "Upload:": "Subida:",
+ "Uploaded": "Subido",
+ "Uploading": "Subiendo",
+ "Uptime": "Tiempo en marcha",
+ "Usage": "Uso",
+ "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema": "Uso: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema",
+ "Usage: disk [show|stats|config |monitor]": "Uso: disk [show|stats|config |monitor]",
+ "Usage: network [show|stats|config |optimize|monitor]": "Uso: network [show|stats|config |optimize|monitor]",
+ "Use 'btbt daemon restart' or restart the daemon manually.": "Use «btbt daemon restart» o reinicie el demonio manualmente.",
+ "Use --confirm to proceed with restore": "Use --confirm para continuar con la restauración",
+ "Use --force to force kill": "Use --force para forzar la terminación",
+ "Use Protocol v2 only (disable v1)": "Usar solo protocolo v2 (desactivar v1)",
+ "Use memory mapping": "Usar asignación en memoria (mmap)",
+ "Using IPC port %d from main config": "Usando puerto IPC %d de la configuración principal",
+ "Using daemon config file: port=%d, api_key_present=%s": "Usando archivo de configuración del demonio: puerto=%d, api_key_present=%s",
+ "Using daemon executor for magnet command": "Usando ejecutor del demonio para el comando magnet",
+ "Using default IPC port %d (daemon config file may not exist)": "Usando puerto IPC predeterminado %d (puede no existir el archivo de config. del demonio)",
+ "Utilization Median": "Mediana de utilización",
+ "Utilization Range": "Rango de utilización",
+ "Utilization Samples": "Muestras de utilización",
+ "V1 torrent generation not yet implemented": "Generación de torrent v1 aún no implementada",
+ "VS Code Dark": "VS Code oscuro",
+ "Validate merged file overlay only; do not write": "Validar solo la superposición del archivo fusionado; no escribir",
+ "Validate only; do not write the config file": "Solo validar; no escribir el archivo de configuración",
+ "Validation error: %s": "Error de validación: %s",
+ "Value to set (use for strings with spaces or JSON); overrides positional VALUE": "Valor a establecer (útil para cadenas con espacios o JSON); sobrescribe VALUE posicional",
+ "Verification complete: {verified} verified, {failed} failed out of {total}": "Verificación completada: {verified} correctos, {failed} fallidos de {total}",
+ "Verification failed: {error}": "Verificación fallida: {error}",
+ "Verify Files": "Verificar archivos",
+ "Visual": "Visual",
+ "Wait for Metadata": "Esperar metadatos",
+ "Wait for metadata and prompt for file selection (interactive only)": "Esperar metadatos y solicitar selección de archivos (solo interactivo)",
+ "Warnings:": "Advertencias:",
+ "WebSocket error in batch receive: %s": "Error WebSocket en recepción por lotes: %s",
+ "WebSocket error: %s": "Error WebSocket: %s",
+ "WebSocket receive loop error: %s": "Error en bucle de recepción WebSocket: %s",
+ "WebTorrent": "WebTorrent",
+ "Whitelist Size": "Tamaño de lista blanca",
+ "Whitelisted Peers": "Pares en lista blanca",
+ "Windows-specific error checking daemon (os.kill() issue): %s - no PID file found, will create local session": "Error específico de Windows al comprobar el demonio (os.kill()): %s — no hay archivo PID; se creará sesión local",
+ "Write Batch Timeout": "Tiempo de espera de lote de escritura",
+ "Write batch size (KiB)": "Tamaño de lote de escritura (KiB)",
+ "Write buffer size (KiB)": "Tamaño de búfer de escritura (KiB)",
+ "Write merged config to global config file": "Escribir configuración fusionada en el archivo global",
+ "Write merged config to project local ccbt.toml": "Escribir configuración fusionada en ccbt.toml local del proyecto",
+ "Write-Back Cache": "Caché de write-back",
+ "Writing export file...": "Escribiendo archivo de exportación...",
+ "Wrote catalog to {path}": "Catálogo escrito en {path}",
+ "XET Folders": "Carpetas XET",
+ "Xet Protocol Options:\n\nXet enables content-defined chunking and deduplication.\nUseful for reducing storage when downloading similar content.": "Opciones del protocolo Xet:\n\nXet permite trozos definidos por contenido y deduplicación.\nÚtil para reducir almacenamiento al descargar contenido similar.",
+ "Xet management": "Gestión Xet",
+ "You can skip waiting and continue with all files selected.": "Puede omitir la espera y continuar con todos los archivos seleccionados.",
+ "Zero-state count": "Recuento de estado cero",
+ "[blue]Progress: {verified}/{total} pieces verified[/blue]": "[blue]Progreso: {verified}/{total} piezas verificadas[/blue]",
+ "[blue]Running: {command}[/blue]": "[blue]Ejecutando: {command}[/blue]",
+ "[bold green]Share link:[/bold green]": "[bold green]Enlace para compartir:[/bold green]",
+ "[bold]Aliases ({count}):[/bold]\n": "[bold]Alias ({count}):[/bold]\n",
+ "[bold]Allowlist ({count} peers):[/bold]\n": "[bold]Lista permitida ({count} pares):[/bold]\n",
+ "[bold]Configuration:[/bold]": "[bold]Configuración:[/bold]",
+ "[bold]Discovering NAT devices...[/bold]\n": "[bold]Descubriendo dispositivos NAT...[/bold]\n",
+ "[bold]Mapping {protocol} port {port}...[/bold]": "[bold]Asignando puerto {protocol} {port}...[/bold]",
+ "[bold]NAT Traversal Status[/bold]\n": "[bold]Estado de NAT traversal[/bold]\n",
+ "[bold]Removing {protocol} port mapping for port {port}...[/bold]": "[bold]Quitando asignación de puerto {protocol} para puerto {port}...[/bold]",
+ "[bold]Sync Mode for: {path}[/bold]\n": "[bold]Modo de sincronización para: {path}[/bold]\n",
+ "[bold]Sync Status for: {path}[/bold]\n": "[bold]Estado de sincronización para: {path}[/bold]\n",
+ "[bold]Xet Cache Information[/bold]\n": "[bold]Información de caché Xet[/bold]\n",
+ "[bold]Xet Deduplication Cache Statistics[/bold]\n": "[bold]Estadísticas de caché de deduplicación Xet[/bold]\n",
+ "[bold]Xet Protocol Status[/bold]\n": "[bold]Estado del protocolo Xet[/bold]\n",
+ "[cyan]Checking for existing daemon instance...[/cyan]": "[cyan]Comprobando si ya hay una instancia del demonio...[/cyan]",
+ "[cyan]Creating {format} torrent...[/cyan]": "[cyan]Creando torrent {format}...[/cyan]",
+ "[cyan]Download:[/cyan] {rate:.2f} KiB/s": "[cyan]Descarga:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Initializing configuration...[/cyan]": "[cyan]Inicializando configuración...[/cyan]",
+ "[cyan]Loading filter from: {file_path}[/cyan]": "[cyan]Cargando filtro desde: {file_path}[/cyan]",
+ "[cyan]Restarting daemon...[/cyan]": "[cyan]Reiniciando demonio...[/cyan]",
+ "[cyan]Running diagnostic checks...[/cyan]\n": "[cyan]Ejecutando comprobaciones de diagnóstico...[/cyan]\n",
+ "[cyan]Starting daemon in background...[/cyan]": "[cyan]Iniciando demonio en segundo plano...[/cyan]",
+ "[cyan]Starting daemon in foreground mode...[/cyan]": "[cyan]Iniciando demonio en primer plano...[/cyan]",
+ "[cyan]Testing proxy connection to {host}:{port}...[/cyan]": "[cyan]Probando conexión al proxy {host}:{port}...[/cyan]",
+ "[cyan]Torrents:[/cyan] {num_torrents}": "[cyan]Torrents:[/cyan] {num_torrents}",
+ "[cyan]Updating filter lists from {count} URL(s)...[/cyan]": "[cyan]Actualizando listas de filtro desde {count} URL(s)...[/cyan]",
+ "[cyan]Upload:[/cyan] {rate:.2f} KiB/s": "[cyan]Subida:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Uptime:[/cyan] {uptime:.1f}s": "[cyan]Tiempo en marcha:[/cyan] {uptime:.1f}s",
+ "[cyan]Using custom IPC port: {port}[/cyan]": "[cyan]Usando puerto IPC personalizado: {port}[/cyan]",
+ "[cyan]Waiting for daemon to be ready...[/cyan]": "[cyan]Esperando a que el demonio esté listo...[/cyan]",
+ "[dim] uv run btbt daemon start --foreground[/dim]": "[dim] uv run btbt daemon start --foreground[/dim]",
+ "[dim]Daemon may still be starting. Use 'btbt daemon status' to check.[/dim]": "[dim]El demonio puede seguir iniciándose. Use «btbt daemon status» para comprobar.[/dim]",
+ "[dim]Info hash v1 (SHA-1): {hash}...[/dim]": "[dim]Info hash v1 (SHA-1): {hash}...[/dim]",
+ "[dim]Info hash v2 (SHA-256): {hash}...[/dim]": "[dim]Info hash v2 (SHA-256): {hash}...[/dim]",
+ "[dim]No active port mappings[/dim]": "[dim]Sin asignaciones de puerto activas[/dim]",
+ "[dim]Output: {path}[/dim]": "[dim]Salida: {path}[/dim]",
+ "[dim]Please restart manually: 'btbt daemon restart'[/dim]": "[dim]Reinicie manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Please restart the daemon manually: 'btbt daemon restart'[/dim]": "[dim]Reinicie el demonio manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Protocol: {method}[/dim]": "[dim]Protocolo: {method}[/dim]",
+ "[dim]See daemon log: {path}[/dim]": "[dim]Vea el registro del demonio: {path}[/dim]",
+ "[dim]Source: {path}[/dim]": "[dim]Origen: {path}[/dim]",
+ "[dim]Trackers: {count}[/dim]": "[dim]Trackers: {count}[/dim]",
+ "[dim]Try running with --foreground flag to see detailed error output:[/dim]": "[dim]Intente con la opción --foreground para ver el error detallado:[/dim]",
+ "[dim]Use 'btbt daemon status' to check daemon status[/dim]": "[dim]Use «btbt daemon status» para el estado del demonio[/dim]",
+ "[dim]Use -v flag for more details or check daemon logs[/dim]": "[dim]Use -v para más detalles o revise los registros del demonio[/dim]",
+ "[dim]Web seeds: {count}[/dim]": "[dim]Web seeds: {count}[/dim]",
+ "[green]ALLOWED[/green]": "[green]PERMITIDO[/green]",
+ "[green]Active Protocol:[/green] {method}": "[green]Protocolo activo:[/green] {method}",
+ "[green]Added alert rule {name}[/green]": "[green]Regla de alerta {name} añadida[/green]",
+ "[green]Added to IPFS:[/green] {cid}": "[green]Añadido a IPFS:[/green] {cid}",
+ "[green]Applying {preset} optimizations...[/green]": "[green]Aplicando optimizaciones {preset}...[/green]",
+ "[green]Benchmark results:[/green] {results}": "[green]Resultados de benchmark:[/green] {results}",
+ "[green]CA certificates path set to {path}. Configuration saved to {config_file}[/green]": "[green]Ruta de certificados CA establecida en {path}. Configuración guardada en {config_file}[/green]",
+ "[green]Checkpoint for {hash} is valid[/green]": "[green]Punto de control para {hash} es válido[/green]",
+ "[green]Checkpoint for {info_hash} is valid[/green]": "[green]Punto de control para {info_hash} es válido[/green]",
+ "[green]Checkpoint refreshed for {hash}[/green]": "[green]Punto de control actualizado para {hash}[/green]",
+ "[green]Checkpoint reloaded for {hash}[/green]": "[green]Punto de control recargado para {hash}[/green]",
+ "[green]Checkpoint saved for torrent[/green]": "[green]Punto de control guardado para el torrent[/green]",
+ "[green]Checkpoint saved[/green]": "[green]Punto de control guardado[/green]",
+ "[green]Checkpoint valid[/green]": "[green]Punto de control válido[/green]",
+ "[green]Cleared all active alerts[/green]": "[green]Se borraron todas las alertas activas[/green]",
+ "[green]Cleared queue[/green]": "[green]Cola vaciada[/green]",
+ "[green]Client certificate set. Configuration saved to {config_file}[/green]": "[green]Certificado de cliente establecido. Configuración guardada en {config_file}[/green]",
+ "[green]Connected to daemon[/green]": "[green]Conectado al demonio[/green]",
+ "[green]Content pinned[/green]": "[green]Contenido fijado[/green]",
+ "[green]Content saved to:[/green] {output}": "[green]Contenido guardado en:[/green] {output}",
+ "[green]DHT aggressive mode {mode} for torrent: {info_hash}[/green]": "[green]Modo DHT agresivo {mode} para torrent: {info_hash}[/green]",
+ "[green]Daemon is running[/green] (PID: {pid})": "[green]El demonio está en ejecución[/green] (PID: {pid})",
+ "[green]Daemon restarted successfully[/green]": "[green]Demonio reiniciado correctamente[/green]",
+ "[green]Daemon stopped gracefully[/green]": "[green]Demonio detenido correctamente[/green]",
+ "[green]Daemon stopped[/green]": "[green]Demonio detenido[/green]",
+ "[green]Deleted checkpoint for {hash}[/green]": "[green]Punto de control eliminado para {hash}[/green]",
+ "[green]Deleted checkpoint for {info_hash}[/green]": "[green]Punto de control eliminado para {info_hash}[/green]",
+ "[green]Deselected all files.[/green]": "[green]Todos los archivos deseleccionados.[/green]",
+ "[green]Deselected all files[/green]": "[green]Todos los archivos deseleccionados[/green]",
+ "[green]Deselected {count} file(s)[/green]": "[green]Deseleccionado(s) {count} archivo(s)[/green]",
+ "[green]External IP:[/green] {ip}": "[green]IP externa:[/green] {ip}",
+ "[green]Force started {count} torrent(s)[/green]": "[green]Forzado el inicio de {count} torrent(s)[/green]",
+ "[green]Found checkpoint for: {torrent_name}[/green]": "[green]Punto de control encontrado para: {torrent_name}[/green]",
+ "[green]Integrity verification passed: {count} pieces verified[/green]": "[green]Verificación de integridad correcta: {count} piezas verificadas[/green]",
+ "[green]Loaded alert rules from {path}[/green]": "[green]Reglas de alerta cargadas desde {path}[/green]",
+ "[green]Loaded {count} alert rules from {path}[/green]": "[green]Cargadas {count} reglas de alerta desde {path}[/green]",
+ "[green]Locale set to: {locale_code}[/green]": "[green]Configuración regional establecida en: {locale_code}[/green]",
+ "[green]Magnet link added to daemon: {info_hash}[/green]": "[green]Enlace magnet añadido al demonio: {info_hash}[/green]",
+ "[green]Moved to position {position}[/green]": "[green]Movido a la posición {position}[/green]",
+ "[green]Network configuration looks optimal![/green]": "[green]¡La configuración de red parece óptima![/green]",
+ "[green]No checkpoints older than {days} days found[/green]": "[green]No hay puntos de control con más de {days} días[/green]",
+ "[green]Optimizations applied successfully![/green]\n[yellow]Note: Some changes may require restart to take effect.[/yellow]": "[green]¡Optimizaciones aplicadas correctamente![/green]\n[yellow]Nota: algunos cambios pueden requerir reinicio.[/yellow]",
+ "[green]Optimizations saved to {path}[/green]": "[green]Optimizaciones guardadas en {path}[/green]",
+ "[green]PEX refreshed for torrent: {info_hash}[/green]": "[green]PEX actualizado para torrent: {info_hash}[/green]",
+ "[green]Paused torrent[/green]": "[green]Torrent pausado[/green]",
+ "[green]Paused {count} torrent(s)[/green]": "[green]Pausado(s) {count} torrent(s)[/green]",
+ "[green]Peer validation hooks are enabled by configuration[/green]": "[green]Los hooks de validación de pares están habilitados por configuración[/green]",
+ "[green]Per-peer rate limit for {peer_key}: {limit}[/green]": "[green]Límite de velocidad por par para {peer_key}: {limit}[/green]",
+ "[green]Per-peer rate limit set: {peer_key} = {upload} KiB/s[/green]": "[green]Límite por par establecido: {peer_key} = {upload} KiB/s[/green]",
+ "[green]Performing basic configuration scan...[/green]": "[green]Realizando análisis básico de configuración...[/green]",
+ "[green]Pinned:[/green] {cid}": "[green]Fijado:[/green] {cid}",
+ "[green]Proxy configuration saved to {config_file}[/green]": "[green]Configuración del proxy guardada en {config_file}[/green]",
+ "[green]Proxy configuration updated successfully[/green]": "[green]Configuración del proxy actualizada correctamente[/green]",
+ "[green]Proxy has been disabled[/green]": "[green]El proxy se ha desactivado[/green]",
+ "[green]Removed alert rule {name}[/green]": "[green]Regla de alerta {name} eliminada[/green]",
+ "[green]Removed torrent from queue[/green]": "[green]Torrent quitado de la cola[/green]",
+ "[green]Reset all options for torrent {hash}[/green]": "[green]Todas las opciones restablecidas para torrent {hash}[/green]",
+ "[green]Reset {key} for torrent {hash}[/green]": "[green]Restablecido {key} para torrent {hash}[/green]",
+ "[green]Restored checkpoint for: {name}[/green]\nInfo hash: {hash}": "[green]Punto de control restaurado para: {name}[/green]\nHash de información: {hash}",
+ "[green]Resume data structure is valid[/green]": "[green]La estructura de datos de reanudación es válida[/green]",
+ "[green]Resumed torrent[/green]": "[green]Torrent reanudado[/green]",
+ "[green]Resumed {count} torrent(s)[/green]": "[green]Reanudado(s) {count} torrent(s)[/green]",
+ "[green]Resuming from checkpoint[/green]": "[green]Reanudando desde el punto de control[/green]",
+ "[green]SSL certificate verification enabled. Configuration saved to {config_file}[/green]": "[green]Verificación de certificado SSL habilitada. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for peers disabled. Configuration saved to {config_file}[/green]": "[green]SSL para pares desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for peers enabled (experimental). Configuration saved to {config_file}[/green]": "[green]SSL para pares habilitado (experimental). Configuración guardada en {config_file}[/green]",
+ "[green]SSL for trackers disabled. Configuration saved to {config_file}[/green]": "[green]SSL para trackers desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL for trackers enabled. Configuration saved to {config_file}[/green]": "[green]SSL para trackers habilitado. Configuración guardada en {config_file}[/green]",
+ "[green]Saved alert rules to {path}[/green]": "[green]Reglas de alerta guardadas en {path}[/green]",
+ "[green]Saved resume data for {hash}[/green]": "[green]Datos de reanudación guardados para {hash}[/green]",
+ "[green]Selected all files[/green]": "[green]Todos los archivos seleccionados[/green]",
+ "[green]Selected {count} file(s).[/green]": "[green]Seleccionado(s) {count} archivo(s).[/green]",
+ "[green]Selected {count} file(s)[/green]": "[green]Seleccionado(s) {count} archivo(s)[/green]",
+ "[green]Set file {index} priority to {priority}[/green]": "[green]Prioridad del archivo {index} establecida en {priority}[/green]",
+ "[green]Set priority to {priority}[/green]": "[green]Prioridad establecida en {priority}[/green]",
+ "[green]Set rate limit for {count} peers: {upload} KiB/s[/green]": "[green]Límite de velocidad para {count} pares: {upload} KiB/s[/green]",
+ "[green]Set {key} = {value} for torrent {hash}[/green]": "[green]Establecido {key} = {value} para torrent {hash}[/green]",
+ "[green]Successfully resumed download: {hash}[/green]": "[green]Descarga reanudada correctamente: {hash}[/green]",
+ "[green]Successfully resumed download: {resumed_info_hash}[/green]": "[green]Descarga reanudada correctamente: {resumed_info_hash}[/green]",
+ "[green]TLS protocol version set to {version}. Configuration saved to {config_file}[/green]": "[green]Versión de protocolo TLS establecida en {version}. Configuración guardada en {config_file}[/green]",
+ "[green]Tested rule {name} with value {value}[/green]": "[green]Regla {name} probada con valor {value}[/green]",
+ "[green]Torrent added to daemon: {info_hash}[/green]": "[green]Torrent añadido al demonio: {info_hash}[/green]",
+ "[green]Torrent cancelled: {info_hash}{checkpoint_info}[/green]": "[green]Torrent cancelado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent force started: {info_hash}[/green]": "[green]Torrent forzado a iniciar: {info_hash}[/green]",
+ "[green]Torrent paused: {info_hash}{checkpoint_info}[/green]": "[green]Torrent pausado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent resumed: {info_hash}{checkpoint_info}[/green]": "[green]Torrent reanudado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Tracker added: {url} to torrent {info_hash}[/green]": "[green]Tracker {url} añadido al torrent {info_hash}[/green]",
+ "[green]Tracker removed: {url} from torrent {info_hash}[/green]": "[green]Tracker {url} quitado del torrent {info_hash}[/green]",
+ "[green]Unpinned:[/green] {cid}": "[green]Desfijado:[/green] {cid}",
+ "[green]Updated {key} to {value}[/green]": "[green]Actualizado {key} a {value}[/green]",
+ "[green]Wrote metrics to {path}[/green]": "[green]Métricas escritas en {path}[/green]",
+ "[green]{message}: {config_file}[/green]": "[green]{message}: {config_file}[/green]",
+ "[green]✓ Port mapping removed[/green]": "[green]✓ Asignación de puerto eliminada[/green]",
+ "[green]✓ Port mapping successful![/green]": "[green]✓ ¡Asignación de puerto correcta![/green]",
+ "[green]✓ Port mappings refreshed[/green]": "[green]✓ Asignaciones de puerto actualizadas[/green]",
+ "[green]✓ Proxy connection test successful[/green]": "[green]✓ Prueba de conexión al proxy correcta[/green]",
+ "[green]✓ Torrent created successfully: {path}[/green]": "[green]✓ Torrent creado correctamente: {path}[/green]",
+ "[green]✓[/green] Added filter rule: {ip_range} ({mode})": "[green]✓[/green] Regla de filtro añadida: {ip_range} ({mode})",
+ "[green]✓[/green] Added peer {peer_id} to allowlist": "[green]✓[/green] Par {peer_id} añadido a la lista permitida",
+ "[green]✓[/green] Added peer {peer_id} to allowlist with alias '{alias}'": "[green]✓[/green] Par {peer_id} añadido a la lista permitida con alias '{alias}'",
+ "[green]✓[/green] Cleaned {cleaned} unused chunks": "[green]✓[/green] Limpiados {cleaned} trozos no usados",
+ "[green]✓[/green] Configuration saved to {file}": "[green]✓[/green] Configuración guardada en {file}",
+ "[green]✓[/green] Daemon process started (PID {pid})": "[green]✓[/green] Proceso del demonio iniciado (PID {pid})",
+ "[green]✓[/green] Daemon started successfully (PID {pid}, took {elapsed:.1f}s)": "[green]✓[/green] Demonio iniciado correctamente (PID {pid}, tardó {elapsed:.1f}s)",
+ "[green]✓[/green] Folder sync started": "[green]✓[/green] Sincronización de carpeta iniciada",
+ "[green]✓[/green] Generated .tonic file: {file}": "[green]✓[/green] Archivo .tonic generado: {file}",
+ "[green]✓[/green] Generated new API key for daemon": "[green]✓[/green] Nueva clave de API generada para el demonio",
+ "[green]✓[/green] Generated tonic?: link:": "[green]✓[/green] Generated tonic?: link:",
+ "[green]✓[/green] Loaded {loaded} rules from {file_path}": "[green]✓[/green] Cargadas {loaded} reglas desde {file_path}",
+ "[green]✓[/green] Loaded {total_loaded} total rules": "[green]✓[/green] Cargadas {total_loaded} reglas en total",
+ "[green]✓[/green] Removed alias for peer {peer_id}": "[green]✓[/green] Alias eliminado para el par {peer_id}",
+ "[green]✓[/green] Removed filter rule: {ip_range}": "[green]✓[/green] Regla de filtro eliminada: {ip_range}",
+ "[green]✓[/green] Removed peer {peer_id} from allowlist": "[green]✓[/green] Par {peer_id} quitado de la lista permitida",
+ "[green]✓[/green] Set alias '{alias}' for peer {peer_id}": "[green]✓[/green] Alias '{alias}' establecido para el par {peer_id}",
+ "[green]✓[/green] Set {key} = {value}": "[green]✓[/green] Establecido {key} = {value}",
+ "[green]✓[/green] Successfully updated {count} filter list(s)": "[green]✓[/green] Actualizadas correctamente {count} lista(s) de filtro",
+ "[green]✓[/green] Sync mode updated": "[green]✓[/green] Modo de sincronización actualizado",
+ "[green]✓[/green] Tonic link:": "[green]✓[/green] Enlace tonic:",
+ "[green]✓[/green] Updated config file: {file}": "[green]✓[/green] Archivo de configuración actualizado: {file}",
+ "[green]✓[/green] Xet protocol enabled": "[green]✓[/green] Protocolo Xet habilitado",
+ "[green]✓[/green] uTP configuration reset to defaults": "[green]✓[/green] Configuración uTP restablecida a valores predeterminados",
+ "[green]✓[/green] uTP transport enabled": "[green]✓[/green] Transporte uTP habilitado",
+ "[red]--name is required to remove a rule[/red]": "[red]Se requiere --name para quitar una regla[/red]",
+ "[red]--name is required to test a rule[/red]": "[red]Se requiere --name para probar una regla[/red]",
+ "[red]--name, --metric and --condition are required to add a rule[/red]": "[red]Se requieren --name, --metric y --condition para añadir una regla[/red]",
+ "[red]--value is required with --test[/red]": "[red]Se requiere --value con --test[/red]",
+ "[red]BLOCKED[/red]": "[red]BLOQUEADO[/red]",
+ "[red]Certificate file does not exist: {path}[/red]": "[red]El archivo de certificado no existe: {path}[/red]",
+ "[red]Certificate path must be a file: {path}[/red]": "[red]La ruta del certificado debe ser un archivo: {path}[/red]",
+ "[red]Configuration key not found: {key}[/red]": "[red]Clave de configuración no encontrada: {key}[/red]",
+ "[red]Content not found: {cid}[/red]": "[red]Contenido no encontrado: {cid}[/red]",
+ "[red]Daemon is not running[/red]": "[red]El demonio no está en ejecución[/red]",
+ "[red]Daemon process crashed[/red]": "[red]El proceso del demonio falló[/red]",
+ "[red]Dashboard error: {e}[/red]": "[red]Error del panel: {e}[/red]",
+ "[red]Directories not yet supported[/red]": "[red]Los directorios aún no están soportados[/red]",
+ "[red]Error adding content: {e}[/red]": "[red]Error al añadir contenido: {e}[/red]",
+ "[red]Error adding peer to allowlist: {e}[/red]": "[red]Error al añadir par a la lista permitida: {e}[/red]",
+ "[red]Error disabling SSL for peers: {e}[/red]": "[red]Error al desactivar SSL para pares: {e}[/red]",
+ "[red]Error disabling SSL for trackers: {e}[/red]": "[red]Error al desactivar SSL para trackers: {e}[/red]",
+ "[red]Error disabling Xet protocol: {e}[/red]": "[red]Error al desactivar el protocolo Xet: {e}[/red]",
+ "[red]Error disabling certificate verification: {e}[/red]": "[red]Error al desactivar la verificación de certificados: {e}[/red]",
+ "[red]Error during cleanup: {e}[/red]": "[red]Error durante la limpieza: {e}[/red]",
+ "[red]Error enabling SSL for peers: {e}[/red]": "[red]Error al activar SSL para pares: {e}[/red]",
+ "[red]Error enabling SSL for trackers: {e}[/red]": "[red]Error al activar SSL para trackers: {e}[/red]",
+ "[red]Error enabling Xet protocol: {e}[/red]": "[red]Error al activar el protocolo Xet: {e}[/red]",
+ "[red]Error enabling certificate verification: {e}[/red]": "[red]Error al activar la verificación de certificados: {e}[/red]",
+ "[red]Error ensuring daemon is running: {e}[/red]": "[red]Error al asegurar que el demonio está en ejecución: {e}[/red]",
+ "[red]Error generating .tonic file: {e}[/red]": "[red]Error al generar el archivo .tonic: {e}[/red]",
+ "[red]Error generating tonic link: {e}[/red]": "[red]Error al generar el enlace tonic: {e}[/red]",
+ "[red]Error getting SSL status: {e}[/red]": "[red]Error al obtener el estado SSL: {e}[/red]",
+ "[red]Error getting Xet status: {e}[/red]": "[red]Error al obtener el estado Xet: {e}[/red]",
+ "[red]Error getting content: {e}[/red]": "[red]Error al obtener el contenido: {e}[/red]",
+ "[red]Error getting peers: {e}[/red]": "[red]Error al obtener los pares: {e}[/red]",
+ "[red]Error getting stats: {e}[/red]": "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error getting status: {e}[/red]": "[red]Error al obtener el estado: {e}[/red]",
+ "[red]Error getting sync mode: {e}[/red]": "[red]Error al obtener el modo de sincronización: {e}[/red]",
+ "[red]Error listing aliases: {e}[/red]": "[red]Error al listar alias: {e}[/red]",
+ "[red]Error listing allowlist: {e}[/red]": "[red]Error al listar la lista permitida: {e}[/red]",
+ "[red]Error pinning content: {e}[/red]": "[red]Error al fijar contenido: {e}[/red]",
+ "[red]Error reading authenticated swarm status: {e}[/red]": "[red]Error al leer el estado del enjambre autenticado: {e}[/red]",
+ "[red]Error removing alias: {e}[/red]": "[red]Error al eliminar alias: {e}[/red]",
+ "[red]Error removing peer from allowlist: {e}[/red]": "[red]Error al quitar par de la lista permitida: {e}[/red]",
+ "[red]Error restarting daemon: {e}[/red]": "[red]Error al reiniciar el demonio: {e}[/red]",
+ "[red]Error retrieving cache info: {e}[/red]": "[red]Error al obtener información de caché: {e}[/red]",
+ "[red]Error retrieving disk statistics: {error}[/red]": "[red]Error al obtener estadísticas de disco: {error}[/red]",
+ "[red]Error retrieving network statistics: {error}[/red]": "[red]Error al obtener estadísticas de red: {error}[/red]",
+ "[red]Error retrieving stats: {e}[/red]": "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error setting CA certificates path: {e}[/red]": "[red]Error al establecer la ruta de certificados CA: {e}[/red]",
+ "[red]Error setting alias: {e}[/red]": "[red]Error al establecer alias: {e}[/red]",
+ "[red]Error setting client certificate: {e}[/red]": "[red]Error al establecer certificado de cliente: {e}[/red]",
+ "[red]Error setting protocol version: {e}[/red]": "[red]Error al establecer la versión de protocolo: {e}[/red]",
+ "[red]Error setting sync mode: {e}[/red]": "[red]Error al establecer el modo de sincronización: {e}[/red]",
+ "[red]Error starting sync: {e}[/red]": "[red]Error al iniciar la sincronización: {e}[/red]",
+ "[red]Error unpinning content: {e}[/red]": "[red]Error al desfijar contenido: {e}[/red]",
+ "[red]Error updating authenticated swarm mode: {e}[/red]": "[red]Error al actualizar el modo de enjambre autenticado: {e}[/red]",
+ "[red]Error updating configuration: {error}[/red]": "[red]Error al actualizar la configuración: {error}[/red]",
+ "[red]Error updating discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento: {e}[/red]",
+ "[red]Error updating parse-policy behavior: {e}[/red]": "[red]Error al actualizar el comportamiento de parse-policy: {e}[/red]",
+ "[red]Error updating strict discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento estricto: {e}[/red]",
+ "[red]Error updating trusted IDs: {e}[/red]": "[red]Error al actualizar los ID de confianza: {e}[/red]",
+ "[red]Error: Cannot specify both --hybrid and --v1[/red]": "[red]Error: no puede especificar --hybrid y --v1 a la vez[/red]",
+ "[red]Error: Cannot specify both --v2 and --hybrid[/red]": "[red]Error: no puede especificar --v2 y --hybrid a la vez[/red]",
+ "[red]Error: Cannot specify both --v2 and --v1[/red]": "[red]Error: no puede especificar --v2 y --v1 a la vez[/red]",
+ "[red]Error: Configuration not available[/red]": "[red]Error: configuración no disponible[/red]",
+ "[red]Error: Failed to get daemon status: {error}[/red]": "[red]Error: no se pudo obtener el estado del demonio: {error}[/red]",
+ "[red]Error: Info hash must be 40 hex characters[/red]": "[red]Error: el info hash debe tener 40 caracteres hexadecimales[/red]",
+ "[red]Error: Invalid torrent file: {torrent_file}[/red]": "[red]Error: archivo torrent no válido: {torrent_file}[/red]",
+ "[red]Error: Network configuration not available[/red]": "[red]Error: configuración de red no disponible[/red]",
+ "[red]Error: Piece length must be a power of 2[/red]": "[red]Error: la longitud de pieza debe ser potencia de 2[/red]",
+ "[red]Error: Piece length must be at least 16 KiB (16384 bytes)[/red]": "[red]Error: la longitud de pieza debe ser al menos 16 KiB (16384 bytes)[/red]",
+ "[red]Error: Source directory is empty[/red]": "[red]Error: el directorio de origen está vacío[/red]",
+ "[red]Error: Source path does not exist: {path}[/red]": "[red]Error: la ruta de origen no existe: {path}[/red]",
+ "[red]Error: {e}[/red]": "[red]Error: {e}[/red]",
+ "[red]Error:[/red] Invalid value for {key}: {value}": "[red]Error:[/red] Valor no válido para {key}: {value}",
+ "[red]Error:[/red] Unknown configuration key: {key}": "[red]Error:[/red] Clave de configuración desconocida: {key}",
+ "[red]Export not available in daemon mode[/red]": "[red]Exportación no disponible en modo demonio[/red]",
+ "[red]Failed to add magnet: {error}[/red]": "[red]No se pudo añadir el magnet: {error}[/red]",
+ "[red]Failed to cancel: {error}[/red]": "[red]No se pudo cancelar: {error}[/red]",
+ "[red]Failed to clear active alerts: {e}[/red]": "[red]No se pudieron borrar las alertas activas: {e}[/red]",
+ "[red]Failed to create session[/red]": "[red]No se pudo crear la sesión[/red]",
+ "[red]Failed to disable proxy: {e}[/red]": "[red]No se pudo desactivar el proxy: {e}[/red]",
+ "[red]Failed to force start: {error}[/red]": "[red]No se pudo forzar el inicio: {error}[/red]",
+ "[red]Failed to get proxy status: {e}[/red]": "[red]No se pudo obtener el estado del proxy: {e}[/red]",
+ "[red]Failed to load alert rules: {e}[/red]": "[red]No se pudieron cargar las reglas de alerta: {e}[/red]",
+ "[red]Failed to load rules: {e}[/red]": "[red]No se pudieron cargar las reglas: {e}[/red]",
+ "[red]Failed to pause: {error}[/red]": "[red]No se pudo pausar: {error}[/red]",
+ "[red]Failed to reset options[/red]": "[red]No se pudieron restablecer las opciones[/red]",
+ "[red]Failed to restart daemon[/red]": "[red]No se pudo reiniciar el demonio[/red]",
+ "[red]Failed to resume: {error}[/red]": "[red]No se pudo reanudar: {error}[/red]",
+ "[red]Failed to run tests: {e}[/red]": "[red]No se pudieron ejecutar las pruebas: {e}[/red]",
+ "[red]Failed to save rules: {e}[/red]": "[red]No se pudieron guardar las reglas: {e}[/red]",
+ "[red]Failed to set option[/red]": "[red]No se pudo establecer la opción[/red]",
+ "[red]Failed to set proxy configuration: {e}[/red]": "[red]No se pudo establecer la configuración del proxy: {e}[/red]",
+ "[red]Failed to start daemon. Cannot proceed without daemon.[/red]\n[yellow]Please check:[/yellow]\n 1. Daemon logs for startup errors\n 2. Port conflicts (check if port is already in use)\n 3. Permissions (ensure you have permission to start daemon)\n\n[cyan]To start daemon manually: 'btbt daemon start'[/cyan]": "[red]No se pudo iniciar el demonio. No se puede continuar sin demonio.[/red]\n[yellow]Compruebe:[/yellow]\n 1. Registros del demonio por errores de inicio\n 2. Conflictos de puerto (¿el puerto está en uso?)\n 3. Permisos (¿puede iniciar el demonio?)\n\n[cyan]Para iniciar manualmente: «btbt daemon start»[/cyan]",
+ "[red]Failed to stop: {error}[/red]": "[red]No se pudo detener: {error}[/red]",
+ "[red]Failed to test proxy: {e}[/red]": "[red]No se pudo probar el proxy: {e}[/red]",
+ "[red]Failed to test rule: {e}[/red]": "[red]No se pudo probar la regla: {e}[/red]",
+ "[red]Failed: {error}[/red]": "[red]Fallo: {error}[/red]",
+ "[red]File not found: {e}[/red]": "[red]Archivo no encontrado: {e}[/red]",
+ "[red]IP filter not initialized. Please enable it in configuration.[/red]": "[red]Filtro IP no inicializado. Habilítelo en la configuración.[/red]",
+ "[red]IP filter not initialized.[/red]": "[red]Filtro IP no inicializado.[/red]",
+ "[red]IPFS protocol not available[/red]": "[red]Protocolo IPFS no disponible[/red]",
+ "[red]Import not available in daemon mode[/red]": "[red]Importación no disponible en modo demonio[/red]",
+ "[red]Invalid IP address: {ip}[/red]": "[red]Dirección IP no válida: {ip}[/red]",
+ "[red]Invalid info hash format[/red]": "[red]Formato de info hash no válido[/red]",
+ "[red]Invalid info hash: {hash}[/red]": "[red]Info hash no válido: {hash}[/red]",
+ "[red]Invalid magnet link: {e}[/red]": "[red]Enlace magnet no válido: {e}[/red]",
+ "[red]Invalid public key: {e}[/red]": "[red]Clave pública no válida: {e}[/red]",
+ "[red]Invalid value for {key}: {error}[/red]": "[red]Valor no válido para {key}: {error}[/red]",
+ "[red]Key file does not exist: {path}[/red]": "[red]El archivo de clave no existe: {path}[/red]",
+ "[red]Key path must be a file: {path}[/red]": "[red]La ruta de la clave debe ser un archivo: {path}[/red]",
+ "[red]Metrics error: {e}[/red]": "[red]Error de métricas: {e}[/red]",
+ "[red]No stats found for CID: {cid}[/red]": "[red]No hay estadísticas para el CID: {cid}[/red]",
+ "[red]Path does not exist: {path}[/red]": "[red]La ruta no existe: {path}[/red]",
+ "[red]Path must be a file or directory: {path}[/red]": "[red]La ruta debe ser un archivo o directorio: {path}[/red]",
+ "[red]Peer {peer_id} not found in allowlist[/red]": "[red]Par {peer_id} no encontrado en la lista permitida[/red]",
+ "[red]Proxy error: {e}[/red]": "[red]Error del proxy: {e}[/red]",
+ "[red]Proxy host and port must be configured[/red]": "[red]Deben configurarse host y puerto del proxy[/red]",
+ "[red]Rule not found: {name}[/red]": "[red]Regla no encontrada: {name}[/red]",
+ "[red]Specify CID or use --all[/red]": "[red]Especifique CID o use --all[/red]",
+ "[red]Torrent not found: {hash}[/red]": "[red]Torrent no encontrado: {hash}[/red]",
+ "[red]Unexpected error during resume: {e}[/red]": "[red]Error inesperado al reanudar: {e}[/red]",
+ "[red]Unknown configuration key: {key}[/red]": "[red]Clave de configuración desconocida: {key}[/red]",
+ "[red]Validation error: {e}[/red]": "[red]Error de validación: {e}[/red]",
+ "[red]{msg}[/red]": "[red]{msg}[/red]",
+ "[red]✗ Failed to remove port mapping[/red]": "[red]✗ No se pudo quitar la asignación de puerto[/red]",
+ "[red]✗ Port mapping failed[/red]": "[red]✗ Falló la asignación de puerto[/red]",
+ "[red]✗ Proxy connection test failed[/red]": "[red]✗ Falló la prueba de conexión al proxy[/red]",
+ "[red]✗[/red] Daemon is already running with PID {pid}": "[red]✗[/red] El demonio ya está en ejecución con PID {pid}",
+ "[red]✗[/red] Daemon process (PID {pid}) crashed during startup (after {elapsed:.1f}s)": "[red]✗[/red] El proceso del demonio (PID {pid}) falló durante el inicio (tras {elapsed:.1f}s)",
+ "[red]✗[/red] Daemon process (PID {pid}) exited immediately after starting": "[red]✗[/red] El proceso del demonio (PID {pid}) salió inmediatamente tras iniciar",
+ "[red]✗[/red] Failed to add filter rule: {ip_range}": "[red]✗[/red] No se pudo añadir la regla de filtro: {ip_range}",
+ "[red]✗[/red] Failed to load rules from {file_path}": "[red]✗[/red] No se pudieron cargar reglas desde {file_path}",
+ "[red]✗[/red] Failed to start daemon: {e}": "[red]✗[/red] No se pudo iniciar el demonio: {e}",
+ "[red]✗[/red] Failed to update filter lists": "[red]✗[/red] No se pudieron actualizar las listas de filtro",
+ "[yellow]1. Network Connectivity[/yellow]": "[yellow]1. Conectividad de red[/yellow]",
+ "[yellow]API key not found in config, cannot get detailed status[/yellow]": "[yellow]No se encontró clave de API en la configuración; no se puede obtener estado detallado[/yellow]",
+ "[yellow]Active Protocol:[/yellow] None (not discovered)": "[yellow]Protocolo activo:[/yellow] Ninguno (no descubierto)",
+ "[yellow]Allowlist is empty[/yellow]": "[yellow]La lista permitida está vacía[/yellow]",
+ "[yellow]Authenticated swarm setting updated (configuration not persisted - no config file)[/yellow]": "[yellow]Ajuste de enjambre autenticado actualizado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Authenticated swarm setting updated (test mode, write skipped)[/yellow]": "[yellow]Ajuste de enjambre autenticado actualizado (modo prueba, escritura omitida)[/yellow]",
+ "[yellow]Authenticated swarms not configured[/yellow]": "[yellow]Enjambres autenticados no configurados[/yellow]",
+ "[yellow]Automatic repair not implemented[/yellow]": "[yellow]Reparación automática no implementada[/yellow]",
+ "[yellow]CA certificates path set to {path} (configuration not persisted - no config file)[/yellow]": "[yellow]Ruta de certificados CA en {path} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]CA certificates path set to {path} (skipped write in test mode)[/yellow]": "[yellow]Ruta de certificados CA en {path} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Checkpoint cannot be auto-resumed - no torrent source found[/yellow]": "[yellow]El punto de control no puede reanudarse solo: no se encontró fuente del torrent[/yellow]",
+ "[yellow]Checkpoint for {hash} is missing or invalid[/yellow]": "[yellow]El punto de control para {hash} falta o no es válido[/yellow]",
+ "[yellow]Checkpoint missing/invalid[/yellow]": "[yellow]Punto de control ausente o no válido[/yellow]",
+ "[yellow]Client certificate set (configuration not persisted - no config file)[/yellow]": "[yellow]Certificado de cliente establecido (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Client certificate set (skipped write in test mode)[/yellow]": "[yellow]Certificado de cliente establecido (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Configuration changes require daemon restart.[/yellow]": "[yellow]Los cambios de configuración requieren reiniciar el demonio.[/yellow]",
+ "[yellow]Could not deselect: {error}[/yellow]": "[yellow]No se pudo deseleccionar: {error}[/yellow]",
+ "[yellow]Could not get detailed status via IPC[/yellow]": "[yellow]No se pudo obtener estado detallado por IPC[/yellow]",
+ "[yellow]Could not save to config file: {error}[/yellow]": "[yellow]No se pudo guardar en el archivo de configuración: {error}[/yellow]",
+ "[yellow]Disk I/O manager not running. Statistics unavailable.[/yellow]": "[yellow]El gestor de E/S de disco no está en ejecución. Estadísticas no disponibles.[/yellow]",
+ "[yellow]Dry run: Would clean chunks older than {days} days[/yellow]": "[yellow]Simulación: se limpiarían trozos de más de {days} días[/yellow]",
+ "[yellow]External IP not available[/yellow]": "[yellow]IP externa no disponible[/yellow]",
+ "[yellow]External IP:[/yellow] Not available": "[yellow]IP externa:[/yellow] No disponible",
+ "[yellow]Failed to generate tonic link[/yellow]": "[yellow]No se pudo generar el enlace tonic[/yellow]",
+ "[yellow]Failed to move torrent[/yellow]": "[yellow]No se pudo mover el torrent[/yellow]",
+ "[yellow]Failed to refresh checkpoint for {hash}[/yellow]": "[yellow]No se pudo actualizar el punto de control para {hash}[/yellow]",
+ "[yellow]Failed to reload checkpoint for {hash}[/yellow]": "[yellow]No se pudo recargar el punto de control para {hash}[/yellow]",
+ "[yellow]Fast resume is disabled[/yellow]": "[yellow]Reanudación rápida desactivada[/yellow]",
+ "[yellow]Found checkpoint for: {name}[/yellow]": "[yellow]Punto de control encontrado para: {name}[/yellow]",
+ "[yellow]Found checkpoint for: {torrent_name}[/yellow]": "[yellow]Punto de control encontrado para: {torrent_name}[/yellow]",
+ "[yellow]Full rehash not implemented in CLI; use resume to trigger piece verification[/yellow]": "[yellow]Rehash completo no implementado en CLI; use reanudar para verificar piezas[/yellow]",
+ "[yellow]IP filter not initialized or disabled.[/yellow]": "[yellow]Filtro IP no inicializado o desactivado.[/yellow]",
+ "[yellow]Integrity verification failed: {count} pieces failed[/yellow]": "[yellow]Falló la verificación de integridad: {count} piezas erróneas[/yellow]",
+ "[yellow]NAT Status[/yellow]": "[yellow]Estado NAT[/yellow]",
+ "[yellow]Network optimizer not available[/yellow]": "[yellow]Optimizador de red no disponible[/yellow]",
+ "[yellow]Network statistics not available[/yellow]": "[yellow]Estadísticas de red no disponibles[/yellow]",
+ "[yellow]No active alerts[/yellow]": "[yellow]No hay alertas activas[/yellow]",
+ "[yellow]No alert rules defined[/yellow]": "[yellow]No hay reglas de alerta definidas[/yellow]",
+ "[yellow]No alias found for peer {peer_id}[/yellow]": "[yellow]No hay alias para el par {peer_id}[/yellow]",
+ "[yellow]No aliases found in allowlist[/yellow]": "[yellow]No hay alias en la lista permitida[/yellow]",
+ "[yellow]No authenticated swarms configuration found[/yellow]": "[yellow]No hay configuración de enjambres autenticados[/yellow]",
+ "[yellow]No cached scrape results[/yellow]": "[yellow]No hay resultados de scrape en caché[/yellow]",
+ "[yellow]No checkpoint found for {hash}[/yellow]": "[yellow]No hay punto de control para {hash}[/yellow]",
+ "[yellow]No checkpoint found for {info_hash}[/yellow]": "[yellow]No hay punto de control para {info_hash}[/yellow]",
+ "[yellow]No chunks in cache[/yellow]": "[yellow]No hay trozos en caché[/yellow]",
+ "[yellow]No config file found - configuration not persisted[/yellow]": "[yellow]No se encontró archivo de configuración — no se persistió[/yellow]",
+ "[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]": "[yellow]No hay lista de archivos en {timeout}s; se continúa con selección predeterminada.[/yellow]",
+ "[yellow]No filter URLs configured.[/yellow]": "[yellow]No hay URL de filtro configuradas.[/yellow]",
+ "[yellow]No filter rules configured.[/yellow]": "[yellow]No hay reglas de filtro configuradas.[/yellow]",
+ "[yellow]No optimizations were applied (already optimal or unsupported)[/yellow]": "[yellow]No se aplicaron optimizaciones (ya óptimo o no soportado)[/yellow]",
+ "[yellow]No performance action specified[/yellow]": "[yellow]No se especificó acción de rendimiento[/yellow]",
+ "[yellow]No recover action specified[/yellow]": "[yellow]No se especificó acción de recuperación[/yellow]",
+ "[yellow]No resume data found in checkpoint[/yellow]": "[yellow]No hay datos de reanudación en el punto de control[/yellow]",
+ "[yellow]No security action specified[/yellow]": "[yellow]No se especificó acción de seguridad[/yellow]",
+ "[yellow]No security configuration loaded[/yellow]": "[yellow]No hay configuración de seguridad cargada[/yellow]",
+ "[yellow]No valid indices, keeping default selection.[/yellow]": "[yellow]Índices no válidos; se mantiene la selección predeterminada.[/yellow]",
+ "[yellow]Non-interactive mode, starting fresh download[/yellow]": "[yellow]Modo no interactivo; iniciando descarga nueva[/yellow]",
+ "[yellow]Note: This change is temporary and will be lost on restart. Use config file for persistent changes.[/yellow]": "[yellow]Nota: este cambio es temporal y se perderá al reiniciar. Use archivo de config. para persistir.[/yellow]",
+ "[yellow]Note: Update config file to persist locale setting[/yellow]": "[yellow]Nota: actualice el archivo de configuración para persistir la configuración regional[/yellow]",
+ "[yellow]Note:[/yellow] Configuration change is runtime-only": "[yellow]Nota:[/yellow] El cambio de configuración solo aplica en tiempo de ejecución",
+ "[yellow]Optimization cancelled[/yellow]": "[yellow]Optimización cancelada[/yellow]",
+ "[yellow]Peer {peer_id} not found in allowlist[/yellow]": "[yellow]Par {peer_id} no encontrado en la lista permitida[/yellow]",
+ "[yellow]Please provide the original torrent file or magnet link[/yellow]": "[yellow]Proporcione el archivo torrent original o el enlace magnet[/yellow]",
+ "[yellow]Please use --v2 or --hybrid flags for now.[/yellow]": "[yellow]Por ahora use las opciones --v2 o --hybrid.[/yellow]",
+ "[yellow]Proxy configuration not found[/yellow]": "[yellow]Configuración del proxy no encontrada[/yellow]",
+ "[yellow]Proxy configuration updated (skipped write in test mode)[/yellow]": "[yellow]Configuración del proxy actualizada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Proxy has been disabled (skipped write in test mode)[/yellow]": "[yellow]El proxy se desactivó (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Proxy is not enabled[/yellow]": "[yellow]El proxy no está habilitado[/yellow]",
+ "[yellow]Real-time monitoring not yet implemented[/yellow]": "[yellow]Monitorización en tiempo real aún no implementada[/yellow]",
+ "[yellow]Refresh completed with warnings[/yellow]": "[yellow]Actualización completada con advertencias[/yellow]",
+ "[yellow]Resume data validation found issues:[/yellow]": "[yellow]La validación de datos de reanudación encontró problemas:[/yellow]",
+ "[yellow]Rich not available, starting fresh download[/yellow]": "[yellow]Rich no disponible; iniciando descarga nueva[/yellow]",
+ "[yellow]Rule not found: {ip_range}[/yellow]": "[yellow]Regla no encontrada: {ip_range}[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended). Configuration saved to {config_file}[/yellow]": "[yellow]Verificación de certificado SSL desactivada (no recomendado). Configuración guardada en {config_file}[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended, configuration not persisted - no config file)[/yellow]": "[yellow]Verificación SSL desactivada (no recomendado, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL certificate verification disabled (not recommended, skipped write in test mode)[/yellow]": "[yellow]Verificación SSL desactivada (no recomendado, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL certificate verification enabled (configuration not persisted - no config file)[/yellow]": "[yellow]Verificación SSL habilitada (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL certificate verification enabled (skipped write in test mode)[/yellow]": "[yellow]Verificación SSL habilitada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for peers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para pares desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for peers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL para pares desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for peers enabled (experimental, configuration not persisted - no config file)[/yellow]": "[yellow]SSL para pares habilitado (experimental, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for peers enabled (experimental, skipped write in test mode)[/yellow]": "[yellow]SSL para pares habilitado (experimental, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for trackers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para trackers desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for trackers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL para trackers desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL for trackers enabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL para trackers habilitado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL for trackers enabled (skipped write in test mode)[/yellow]": "[yellow]SSL para trackers habilitado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Select failed: {error}[/yellow]": "[yellow]Error al seleccionar: {error}[/yellow]",
+ "[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]": "[yellow]Use --download-limit/--upload-limit para límites globales; por par vía configuración[/yellow]",
+ "[yellow]Starting fresh download[/yellow]": "[yellow]Iniciando descarga nueva[/yellow]",
+ "[yellow]TLS protocol version set to {version} (configuration not persisted - no config file)[/yellow]": "[yellow]Versión TLS en {version} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]TLS protocol version set to {version} (skipped write in test mode)[/yellow]": "[yellow]Versión TLS en {version} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]The daemon process crashed during initialization.[/yellow]": "[yellow]El proceso del demonio falló durante la inicialización.[/yellow]",
+ "[yellow]The daemon process exited unexpectedly. Check daemon logs for error details.[/yellow]": "[yellow]El proceso del demonio salió de forma inesperada. Revise los registros del demonio.[/yellow]",
+ "[yellow]This usually indicates a configuration error, missing dependency, or initialization failure.[/yellow]": "[yellow]Suele indicar error de configuración, dependencia faltante o fallo de inicialización.[/yellow]",
+ "[yellow]Timeout waiting for daemon (last status: {last_status})[/yellow]": "[yellow]Tiempo de espera del demonio agotado (último estado: {last_status})[/yellow]",
+ "[yellow]To see errors in the terminal, run:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]": "[yellow]Para ver errores en la terminal, ejecute:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]",
+ "[yellow]Toggle encryption via --enable-encryption/--disable-encryption on download/magnet[/yellow]": "[yellow]Active el cifrado con --enable-encryption/--disable-encryption en download/magnet[/yellow]",
+ "[yellow]Torrent not found in queue[/yellow]": "[yellow]Torrent no encontrado en la cola[/yellow]",
+ "[yellow]Torrent not found or not active. Resume data will be automatically saved when torrent completes.[/yellow]": "[yellow]Torrent no encontrado o inactivo. Los datos de reanudación se guardarán al completar el torrent.[/yellow]",
+ "[yellow]Torrent not found[/yellow]": "[yellow]Torrent no encontrado[/yellow]",
+ "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load or --save[/yellow]": "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load o --save[/yellow]",
+ "[yellow]Use -v flag for more details or try --foreground to see error output[/yellow]": "[yellow]Use -v para más detalles o --foreground para ver el error[/yellow]",
+ "[yellow]Warning: Checkpoint save failed[/yellow]": "[yellow]Advertencia: falló al guardar el punto de control[/yellow]",
+ "[yellow]Warning: Configuration changes require daemon restart, but restart was skipped.[/yellow]": "[yellow]Advertencia: los cambios requieren reiniciar el demonio, pero se omitió el reinicio.[/yellow]",
+ "[yellow]Warning: Daemon is running. Diagnostics will test local session which may cause port conflicts.[/yellow]\n[dim]Consider stopping the daemon first: 'btbt daemon exit'[/dim]\n": "[yellow]Advertencia: el demonio está en ejecución. El diagnóstico usará sesión local y puede haber conflictos de puerto.[/yellow]\n[dim]Considere detener el demonio primero: «btbt daemon exit»[/dim]\n",
+ "[yellow]Warning: Error saving checkpoint: {error}[/yellow]": "[yellow]Advertencia: error al guardar punto de control: {error}[/yellow]",
+ "[yellow]Warning: Error stopping session: {e}[/yellow]": "[yellow]Advertencia: error al detener la sesión: {e}[/yellow]",
+ "[yellow]Warning: Failed to save checkpoint: {error}[/yellow]": "[yellow]Advertencia: no se pudo guardar el punto de control: {error}[/yellow]",
+ "[yellow]Warning: Failed to select files: {error}[/yellow]": "[yellow]Advertencia: no se pudieron seleccionar archivos: {error}[/yellow]",
+ "[yellow]Warning: Failed to set queue priority: {error}[/yellow]": "[yellow]Advertencia: no se pudo establecer la prioridad en cola: {error}[/yellow]",
+ "[yellow]Warning: IPC client not available[/yellow]": "[yellow]Advertencia: cliente IPC no disponible[/yellow]",
+ "[yellow]Warning: SSL certificate verification is disabled while SSL is used in strict mode[/yellow]": "[yellow]Advertencia: la verificación SSL está desactivada mientras SSL se usa en modo estricto[/yellow]",
+ "[yellow]Warning: V1 torrent generation is not yet implemented.[/yellow]": "[yellow]Advertencia: la generación de torrent v1 aún no está implementada.[/yellow]",
+ "[yellow]Warning: certificate verification is disabled while SSL is in strict posture[/yellow]": "[yellow]Advertencia: verificación de certificado desactivada con SSL en postura estricta[/yellow]",
+ "[yellow]Would delete {count} checkpoints older than {days} days:[/yellow]": "[yellow]Se eliminarían {count} puntos de control de más de {days} días:[/yellow]",
+ "[yellow]{key} is not set[/yellow]": "[yellow]{key} no está definido[/yellow]",
+ "[yellow]⚠[/yellow] Could not save daemon config to config file: {e}": "[yellow]⚠[/yellow] No se pudo guardar la configuración del demonio: {e}",
+ "[yellow]⚠[/yellow] Daemon process started (PID {pid}) but may not be fully ready yet": "[yellow]⚠[/yellow] Proceso del demonio iniciado (PID {pid}) pero puede no estar listo aún",
+ "[yellow]⚠[/yellow] Daemon startup timeout after {timeout:.1f}s (last status: {last_status})": "[yellow]⚠[/yellow] Tiempo de espera de inicio del demonio tras {timeout:.1f}s (último estado: {last_status})",
+ "[yellow]⚠[/yellow] {errors} errors encountered": "[yellow]⚠[/yellow] Se encontraron {errors} errores",
+ "[yellow]✓[/yellow] Xet protocol disabled": "[yellow]✓[/yellow] Protocolo Xet desactivado",
+ "[yellow]✓[/yellow] uTP transport disabled": "[yellow]✓[/yellow] Transporte uTP desactivado",
+ "_get_executor() returned: executor=%s, is_daemon=%s": "_get_executor() devolvió: executor=%s, is_daemon=%s",
+ "aiortc not installed": "aiortc no instalado",
+ "disabled": "desactivado",
+ "enable_dht={value}": "enable_dht={value}",
+ "enable_pex={value}": "enable_pex={value}",
+ "enabled": "habilitado",
+ "failed": "fallido",
+ "fell": "bajó",
+ "http://tracker.example.com:8080/announce": "http://tracker.example.com:8080/announce",
+ "no": "no",
+ "none": "ninguno",
+ "not ready yet": "aún no listo",
+ "peers": "pares",
+ "pieces": "piezas",
+ "replace: file must be a full valid document; merge: deep-merge into existing target TOML then validate": "replace: el archivo debe ser un documento completo válido; merge: fusión profunda en el TOML de destino y validar",
+ "rose": "subió",
+ "succeeded": "correcto",
+ "tonic share requires the daemon. Start it with: btbt daemon start": "compartir tonic requiere el demonio. Inícielo con: btbt daemon start",
+ "uTP": "uTP",
+ "uTP (uTorrent Transport Protocol) Options:\n\nuTP provides reliable, ordered delivery over UDP with delay-based congestion control (BEP 29).\nUseful for better performance on networks with high latency or packet loss.": "uTP (protocolo de transporte uTorrent). Opciones:\n\nuTP ofrece entrega fiable y ordenada sobre UDP con control de congestión por retardo (BEP 29).\nÚtil en redes con alta latencia o pérdida de paquetes.",
+ "uTP Configuration": "Configuración uTP",
+ "uTP config": "Config. uTP",
+ "uTP configuration reset to defaults via CLI": "Configuración uTP restablecida a valores predeterminados por CLI",
+ "uTP configuration updated: %s = %s": "Configuración uTP actualizada: %s = %s",
+ "uTP transport disabled via CLI": "Transporte uTP desactivado por CLI",
+ "uTP transport enabled": "Transporte uTP habilitado",
+ "uTP transport enabled via CLI": "Transporte uTP habilitado por CLI",
+ "unknown": "desconocido",
+ "unlimited": "ilimitado",
+ "yes": "sí",
+ "{connection} Torrents: {torrents} Active: {active} Paused: {paused} Seeding: {seeding} D: {download}B/s U: {upload}B/s": "{connection} Torrents: {torrents} Activos: {active} Pausados: {paused} Semilla: {seeding} D: {download}B/s S: {upload}B/s",
+ "{graph_tab_id} - Data provider configuration error": "{graph_tab_id} — error de configuración del proveedor de datos",
+ "{graph_tab_id} - Data provider not available": "{graph_tab_id} — proveedor de datos no disponible",
+ "{hours:.1f}h ago": "hace {hours:.1f} h",
+ "{key} = {value}": "{key} = {value}",
+ "{key}: {value}": "{key}: {value}",
+ "{minutes:.0f}m ago": "hace {minutes:.0f} min",
+ "{msg}\n\nPID file path: {path}": "{msg}\n\nRuta del archivo PID: {path}",
+ "{seconds:.0f}s ago": "hace {seconds:.0f} s",
+ "{sub_tab} configuration - Coming soon": "Configuración de {sub_tab} — próximamente",
+ "{sub_tab} content for torrent {hash}... - Coming soon": "Contenido de {sub_tab} para torrent {hash}… — próximamente",
+ "{type} Configuration": "Configuración {type}",
+ "↑ Rate": "↑ Tasa",
+ "↑ Speed": "↑ Velocidad",
+ "↓ Rate": "↓ Tasa",
+ "↓ Speed": "↓ Velocidad",
+ "≥ 80% available": "≥ 80 % disponible",
+ "⏸ Pause": "⏸ Pausa",
+ "▶ Resume": "▶ Reanudar",
+ "⚠️ Daemon restart required to apply changes.\n": "⚠️ Hay que reiniciar el demonio para aplicar los cambios.\n",
+ "✓ Configuration is valid": "✓ La configuración es válida",
+ "✓ No system compatibility warnings": "✓ Sin advertencias de compatibilidad del sistema",
+ "✓ Verify": "✓ Verificar",
+ "✗ Configuration validation failed: {e}": "✗ Validación de configuración fallida: {e}",
+ "📊 Refresh PEX": "📊 Actualizar PEX",
+ "📥 Export State": "📥 Exportar estado",
+ "🔄 Reannounce": "🔄 Reanunciar",
+ "🔍 Rehash": "🔍 Rehash",
+ "🗑 Remove": "🗑 Quitar"
+}
diff --git a/ccbt/i18n/locale_data/es_supplement.json b/ccbt/i18n/locale_data/es_supplement.json
new file mode 100644
index 00000000..16f48265
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_supplement.json
@@ -0,0 +1,1682 @@
+{
+"\n[bold]IP Filter Statistics[/bold]\n": "[bold]Estadísticas del filtro IP[/bold]",
+"\n[bold]IP Filter Test[/bold]\n": "[bold]Prueba de filtro IP[/bold]",
+"\n[cyan]Connection Diagnostics[/cyan]\n": "[cian]Diagnóstico de conexión[/cian]",
+"\n[cyan]Proxy Statistics:[/cyan]": "[cian]Estadísticas de proxy:[/cian]",
+"\n[cyan]Status:[/cyan] {status}": "[cian]Estado:[/cian] {estado}",
+"\n[dim]Press Ctrl+I in main dashboard to manage IPFS content and peers[/dim]": "[dim]Presione Ctrl+I en el panel principal para administrar el contenido IPFS y sus pares[/dim]",
+"\n[dim]Press Ctrl+N in main dashboard to manage NAT settings globally[/dim]": "[dim]Presione Ctrl+N en el panel principal para administrar la configuración de NAT globalmente[/dim]",
+"\n[dim]Press Ctrl+R in main dashboard to view scrape results[/dim]": "[dim]Presione Ctrl+R en el panel principal para ver los resultados del scrape[/dim]",
+"\n[dim]Press Ctrl+U in main dashboard to configure uTP settings globally[/dim]": "[dim]Presione Ctrl+U en el panel principal para configurar los ajustes de uTP globalmente[/dim]",
+"\n[dim]Press Ctrl+X in main dashboard to manage Xet settings globally[/dim]": "[dim]Presione Ctrl+X en el panel principal para administrar la configuración de Xet globalmente[/dim]",
+"\n[green]Diagnostic complete![/green]": "[verde]¡Diagnóstico completo![/verde]",
+"\n[green]✓ Discovery successful![/green]": "[verde] ✓ ¡Descubrimiento exitoso! [/verde]",
+"\n[green]✓[/green] No connection issues detected": "[verde] ✓[/verde] No se detectaron problemas de conexión",
+"\n[yellow]2. DHT Status[/yellow]": "[amarillo]2. Estado de DHT[/amarillo]",
+"\n[yellow]3. Tracker Configuration[/yellow]": "[amarillo]3. Configuración del rastreador[/amarillo]",
+"\n[yellow]4. NAT Configuration[/yellow]": "[amarillo]4. Configuración NAT[/amarillo]",
+"\n[yellow]5. Listen Port[/yellow]": "[amarillo]5. Puerto de escucha[/amarillo]",
+"\n[yellow]6. Session Initialization Test[/yellow]": "[amarillo]6. Prueba de inicialización de sesión[/amarillo]",
+"\n[yellow]Connection Issues[/yellow]": "[amarillo]Problemas de conexión[/amarillo]",
+"\n[yellow]Download interrupted by user[/yellow]": "[amarillo]Descarga interrumpida por el usuario[/amarillo]",
+"\n[yellow]Session Summary[/yellow]": "[amarillo]Resumen de sesión[/amarillo]",
+"\n[yellow]Shutting down daemon...[/yellow]": "[amarillo]Cerrando demonio...[/amarillo]",
+"\n[yellow]TCP Server Status[/yellow]": "[amarillo]Estado del servidor TCP[/amarillo]",
+"\n[yellow]✗ No NAT devices discovered[/yellow]": "[amarillo]✗ No se descubrieron dispositivos NAT[/amarillo]",
+" - {network} ({mode}, priority: {priority})": "- {red} ({modo}, prioridad: {prioridad})",
+" - {hash}... ({format})": "- {hash}.... ({formato})",
+" .tonic file: {path}": "Archivo .tonic: {ruta}",
+" Active Downloading: {count}": "Descarga activa: {count}",
+" Active Mappings: {mappings}": "Asignaciones activas: {mappings}",
+" Active Seeding: {count}": "Siembra activa: {count}",
+" Add the peer first using 'tonic allowlist add'": "Agregue el par primero usando 'agregar lista de permitidos tónicos'",
+" Auth failures: {count}": "Errores de autenticación: {count}",
+" Auto Map Ports: {status}": "Puertos de mapa automático: {status}",
+" Bypass list: {value}": "Lista de omisión: {valor}",
+" Certificate: {path}": "Certificado: {ruta}",
+" Check interval: {seconds}": "Intervalo de verificación: {segundos}",
+" Current mode: {mode}": "Modo actual: {modo}",
+" DHT Enabled: {status}": "DHT habilitado: {estado}",
+" DHT Port: {port}": "Puerto DHT: {puerto}",
+" DHT Routing Table: {size} nodes": "Tabla de enrutamiento DHT: {tamaño} nodos",
+" Default sync mode: {mode}": "Modo de sincronización predeterminado: {mode}",
+" Enabled: {enabled}": "Habilitado: {habilitado}",
+" External IP: {ip}": "IP externa: {ip}",
+" External: {port}": "Externo: {puerto}",
+" Failed: {count}": "Error: {count}",
+" Folder key: {folder_key}": "Clave de carpeta: {folder_key}",
+" Folder key: {key}": "Clave de carpeta: {clave}",
+" For peers: {value}": "Para pares: {valor}",
+" For trackers: {value}": "Para rastreadores: {valor}",
+" For webseeds: {value}": "Para semillas web: {valor}",
+" HTTP Trackers: {status}": "Rastreadores HTTP: {estado}",
+" Host: {host}:{port}": "Anfitrión: {anfitrión}:{puerto}",
+" Internal: {port}": "Interno: {puerto}",
+" Key: {path}": "Clave: {ruta}",
+" Make sure NAT traversal is enabled and a device is discovered": "Asegúrese de que el recorrido NAT esté habilitado y se descubra un dispositivo",
+" Make sure NAT-PMP or UPnP is enabled on your router": "Asegúrese de que NAT-PMP o UPnP esté habilitado en su enrutador",
+" Mode: {mode}": "Modo: {modo}",
+" NAT-PMP: {status}": "NAT-PMP: {estado}",
+" Output directory: {dir}": "Directorio de salida: {dir}",
+" Paused: {count}": "En pausa: {count}",
+" Protocol enabled: {enabled}": "Protocolo habilitado: {habilitado}",
+" Protocol not active (session may not be running)": "Protocolo no activo (es posible que la sesión no se esté ejecutando)",
+" Protocol: {method}": "Protocolo: {método}",
+" Protocol: {protocol}": "Protocolo: {protocolo}",
+" Queued: {count}": "En cola: {count}",
+" Running: {status}": "En ejecución: {estado}",
+" Serving: {status}": "Publicación: {status}",
+" Sessions with Peers: {count}": "Sesiones con compañeros: {count}",
+" Source peers: {peers}": "Pares de origen: {pares}",
+" Successful: {count}": "Exitoso: {count}",
+" Supports DHT: {enabled}": "Admite DHT: {habilitado}",
+" Supports PEX: {enabled}": "Soporta PEX: {habilitado}",
+" Supports XET: {enabled}": "Soporta XET: {habilitado}",
+" TCP Enabled: {status}": "TCP habilitado: {estado}",
+" TCP Port: {port}": "Puerto TCP: {puerto}",
+" Total Connections: {count}": "Conexiones totales: {count}",
+" Total Sessions: {count}": "Sesiones totales: {count}",
+" Total connections: {count}": "Conexiones totales: {count}",
+" Total: {count}": "Total: {cuenta}",
+" Type: {type}": "Tipo: {tipo}",
+" UDP Trackers: {status}": "Rastreadores UDP: {status}",
+" UPnP: {status}": "UPnP: {estado}",
+" Use 'ccbt tonic status' to check sync status": "Utilice 'ccbt tonic status' para comprobar el estado de sincronización",
+" Username: {username}": "Nombre de usuario: {nombre de usuario}",
+" Workspace ID: {id}": "ID del espacio de trabajo: {id}",
+" Workspace sync enabled: {enabled}": "Sincronización del espacio de trabajo habilitada: {habilitada}",
+" XET port: {port}": "Puerto XET: {puerto}",
+" [cyan]Allowed:[/cyan] {allows}": "[cian]Permitido:[/cian] {permite}",
+" [cyan]Blocked:[/cyan] {blocks}": "[cian]Bloqueado:[/cian] {bloques}",
+" [cyan]Enabled:[/cyan] {enabled}": "[cian]Habilitado:[/cian] {habilitado}",
+" [cyan]IP Address:[/cyan] {ip}": "[cian]Dirección IP:[/cian] {ip}",
+" [cyan]IPv4 Ranges:[/cyan] {ipv4_ranges}": "[cian]Rangos de IPv4:[/cian] {ipv4_ranges}",
+" [cyan]IPv6 Ranges:[/cyan] {ipv6_ranges}": "[cian]Rangos de IPv6:[/cian] {ipv6_ranges}",
+" [cyan]Last Update:[/cyan] Never": "[cian]Última actualización:[/cian] Nunca",
+" [cyan]Last Update:[/cyan] {timestamp}": "[cian]Última actualización:[/cian] {marca de tiempo}",
+" [cyan]Mode:[/cyan] {mode}": "[cian]Modo:[/cian] {modo}",
+" [cyan]Status:[/cyan] {status}": "[cian]Estado:[/cian] {estado}",
+" [cyan]Total Checks:[/cyan] {matches}": "[cian]Total de cheques:[/cian] {coincidencias}",
+" [cyan]Total Rules:[/cyan] {total_rules}": "[cian]Reglas totales:[/cian] {total_rules}",
+" [green]✓[/green] Can bind to port {port}": "[verde] ✓[/verde] Puede vincularse al puerto {puerto}",
+" [green]✓[/green] Session initialized successfully": "[verde] ✓[/verde] Sesión inicializada exitosamente",
+" [green]✓[/green] TCP server initialized": "[verde] ✓[/verde] Servidor TCP inicializado",
+" [green]✓[/green] {url}: {loaded} rules": "[verde] ✓[/verde] {url}: reglas {cargadas}",
+" [red]✗[/red] Cannot bind to port: {e}": "[rojo]✗[/rojo] No se puede vincular al puerto: {e}",
+" [red]✗[/red] NAT manager not initialized": "[rojo]✗[/rojo] Administrador NAT no inicializado",
+" [red]✗[/red] Session initialization failed: {e}": "[rojo]✗[/rojo] Falló la inicialización de la sesión: {e}",
+" [red]✗[/red] TCP server not initialized": "[rojo]✗[/rojo] Servidor TCP no inicializado",
+" [red]✗[/red] {url}: failed": "[rojo]✗[/rojo] {url}: fallido",
+" [yellow]⚠[/yellow] DHT client not initialized": "[amarillo]⚠[/amarillo] Cliente DHT no inicializado",
+" [yellow]⚠[/yellow] TCP server not initialized": "[amarillo]⚠[/amarillo] Servidor TCP no inicializado",
+" uTP Enabled: {status}": "uTP habilitado: {estado}",
+" {msg}": "{mensaje}",
+" {warning}": "{advertencia}",
+" ⚠ {warning}": "⚠ {advertencia}",
+" (checkpoint restored)": "(punto de control restaurado)",
+" (checkpoint saved)": "(punto de control guardado)",
+" (no checkpoint found)": "(no se encontró ningún punto de control)",
+" +{count} more": "+{count} más",
+"(no options set)": "(no hay opciones configuradas)",
+"- [yellow]{issue}[/yellow]": "- [amarillo]{problema}[/amarillo]",
+"- {id}: {severity} rule={rule} value={value}": "- {id}: {severidad} regla = {regla} valor = {valor}",
+"- {name}: metric={metric}, cond={condition}, severity={severity}": "- {nombre}: métrica={métrica}, cond={condición}, gravedad={severidad}",
+"... and {count} more": "... y {contar} más",
+"0.1 ms (adaptive)": "0,1 ms (adaptativo)",
+"1 MB (adaptive)": "1 MB (adaptativo)",
+"1-2": "[ES] 1-2",
+"2-4": "[ES] 2-4",
+"25–49% available": "25-49% disponible",
+"4-8": "[ES] 4-8",
+"5 ms (adaptive)": "5 ms (adaptativo)",
+"50 ms (adaptive)": "50 ms (adaptativo)",
+"50–79% available": "50-79% disponible",
+"512 KB (adaptive)": "512 KB (adaptable)",
+"64 KB (adaptive)": "64 KB (adaptable)",
+"ACK Interval": "Intervalo de confirmación",
+"ACK packet send interval": "Intervalo de envío de paquetes ACK",
+"API key or Ed25519 key manager required for WebSocket connection": "Se requiere clave API o administrador de claves Ed25519 para la conexión WebSocket",
+"Action": "Acción",
+"Actions": "Comportamiento",
+"Active Block Requests": "Solicitudes de bloqueo activo",
+"Active Nodes": "Nodos activos",
+"Active Torrents": "Torrentes activos",
+"Adaptive": "Adaptado",
+"Add": "Agregar",
+"Add Torrents": "Agregar torrentes",
+"Add Tracker": "Agregar rastreador",
+"Add magnet succeeded but no info_hash returned": "La adición del imán se realizó correctamente pero no se devolvió info_hash",
+"Add to Session": "Agregar a la sesión",
+"Advanced": "Avanzado",
+"Advanced add torrent": "Avanzado agregar torrent",
+"Advanced configuration (experimental features)": "Configuración avanzada (características experimentales)",
+"Advanced configuration - Data provider/Executor not available": "Configuración avanzada: proveedor de datos/ejecutor no disponible",
+"Aggressive": "Agresivo",
+"Aggressive Mode": "Modo agresivo",
+"Alerts dashboard": "Panel de alertas",
+"All {total} file(s) verified successfully": "Todo el {total} archivo(s) verificado exitosamente",
+"Announce sent": "Anuncio enviado",
+"Apply": "Aplicar",
+"Authentication failed when checking daemon status at %s (status %d). This usually indicates an API key mismatch. Check that the API key in config matches the daemon's API key.": "La autenticación falló al verificar el estado del demonio en %s (estado %d). Esto suele indicar una discrepancia en la clave API. Compruebe que la clave API en la configuración coincida con la clave API del demonio.",
+"Auto-scrape on Add:": "Raspado automático al agregar:",
+"Auto-tuned configuration saved to {path}": "Configuración ajustada automáticamente guardada en {ruta}",
+"Auto-tuning warnings:": "Advertencias de ajuste automático:",
+"Availability": "Disponibilidad",
+"Availability Trend": "Tendencia de disponibilidad",
+"Availability {direction} {delta:+.1f}pp": "Disponibilidad {dirección} {delta:+.1f}pp",
+"Available keys: {keys}": "Claves disponibles: {claves}",
+"Available locales: {locales}": "Configuraciones locales disponibles: {locales}",
+"Average Quality": "Calidad media",
+"Avg Download Rate": "Tasa de descarga promedio",
+"Avg Quality": "Calidad promedio",
+"Avg Upload Rate": "Tasa de carga promedio",
+"Backup complete": "Copia de seguridad completa",
+"Backup created: {path}": "Copia de seguridad creada: {ruta}",
+"Backup destination path": "Ruta de destino de la copia de seguridad",
+"Backup failed": "Error en la copia de seguridad",
+"Ban Peer": "Prohibición de pares",
+"Bandwidth": "Ancho de banda",
+"Bandwidth Utilization": "Utilización del ancho de banda",
+"Bandwidth configuration - Data provider/Executor not available": "Configuración de ancho de banda: proveedor/ejecutor de datos no disponible",
+"Blacklist Size": "Tamaño de la lista negra",
+"Blacklisted IPs ({count})": "IP en lista negra ({count})",
+"Blacklisted Peers": "Compañeros en la lista negra",
+"Block size (KiB)": "Tamaño de bloque (KiB)",
+"Blocked Connections": "Conexiones bloqueadas",
+"Bootstrap Nodes": "Nodos de arranque",
+"Bootstrap health": "Salud de arranque",
+"Bootstrap recovery attempts": "Intentos de recuperación de arranque",
+"Browse and add torrent": "Navegar y agregar torrent",
+"Bytes Downloaded": "Bytes descargados",
+"Bytes Uploaded": "Bytes cargados",
+"CPU": "UPC",
+"CRITICAL: PID file exists (initial=%s, current=%s, path=%s) but code reached local session creation! This will cause port conflicts. Aborting.": "CRÍTICO: El archivo PID existe (inicial=%s, actual=%s, ruta=%s) pero el código alcanzó la creación de la sesión local. Esto provocará conflictos portuarios. Abortando.",
+"Cache Statistics": "Estadísticas de caché",
+"Cache entries: {count}": "Entradas de caché: {count}",
+"Cache hit rate: {rate:.2f}%": "Tasa de aciertos de caché: {rate:.2f}%",
+"Cache size: {size} bytes": "Tamaño de caché: {tamaño} bytes",
+"Cached Scrape Results": "Resultados de raspado en caché",
+"Cached: {cache_size}, Total Seeders: {seeders}, Total Leechers: {leechers}": "En caché: {cache_size}, Total de sembradores: {seeders}, Total de sanguijuelas: {leechers}",
+"Cancel": "Cancelar",
+"Cancel Editing": "Cancelar edición",
+"Cannot auto-resume checkpoint": "No se puede reanudar automáticamente el punto de control",
+"Cannot connect to daemon at %s: %s (daemon may not be running or IPC server not started)": "No se puede conectar al demonio en %s: %s (es posible que el demonio no se esté ejecutando o que el servidor IPC no se haya iniciado)",
+"Cannot connect to daemon. Start daemon with: 'btbt daemon start'": "No se puede conectar con el demonio. Iniciar demonio con: 'btbt daemon start'",
+"Cannot specify both --hybrid and --v1": "No se puede especificar tanto --hybrid como --v1",
+"Cannot specify both --v2 and --hybrid": "No se puede especificar tanto --v2 como --hybrid",
+"Cannot specify both --v2 and --v1": "No se puede especificar tanto --v2 como --v1",
+"Catppuccin": "catuchino",
+"Checkpoint directory": "Directorio de puntos de control",
+"Choked": "ahogado",
+"Choose a playable file first.": "Elija primero un archivo reproducible.",
+"Choose a theme": "Elige un tema",
+"Cleaning up old checkpoints...": "Limpiando viejos puestos de control...",
+"Cleanup complete": "Limpieza completa",
+"Click on 'Global' tab to configure this section": "Haga clic en la pestaña 'Global' para configurar esta sección",
+"Client": "Cliente",
+"Client error checking daemon status at %s: %s (daemon may be starting up)": "Error del cliente al comprobar el estado del demonio en %s: %s (es posible que el demonio se esté iniciando)",
+"Close": "Cerca",
+"Closest Nodes": "Nodos más cercanos",
+"Command '{cmd}' executed successfully": "Comando '{cmd}' ejecutado exitosamente",
+"Command '{cmd}' failed": "El comando '{cmd}' falló",
+"Command executor not available": "Ejecutor de comandos no disponible",
+"Command executor or data provider not available": "Ejecutor de comandos o proveedor de datos no disponible",
+"Compress backup (default: yes)": "Comprimir copia de seguridad (predeterminado: sí)",
+"Compressing backup...": "Comprimiendo copia de seguridad...",
+"Config": "configuración",
+"Configuration": "Configuración",
+"Configuration differences:": "Diferencias de configuración:",
+"Configuration exported to {path}": "Configuración exportada a {ruta}",
+"Configuration imported to {path}": "Configuración importada a {ruta}",
+"Configuration options": "Opciones de configuración",
+"Configuration restored from {path}": "Configuración restaurada desde {ruta}",
+"Configuration saved successfully": "Configuración guardada exitosamente",
+"Configuration saved successfully!": "¡Configuración guardada exitosamente!",
+"Configuration saved successfully.\n": "Configuración guardada exitosamente.",
+"Configuration section": "Sección de configuración",
+"Configuration: {type}\n\nThis configuration section is not yet fully implemented.": "Configuración: {tipo}\n\nEsta sección de configuración aún no está completamente implementada.",
+"Connected Torrents": "Torrentes conectados",
+"Connected to {peers} peer(s), fetching metadata...": "Conectado a {peers} peer(s), obteniendo metadatos...",
+"Connecting to daemon at %s (PID file exists, config_path=%s)": "Conexión al demonio en %s (el archivo PID existe, config_path=%s)",
+"Connecting to daemon at %s (config_path=%s)": "Conectándose al demonio en %s (config_path=%s)",
+"Connecting to peers...": "Conectándose con sus compañeros...",
+"Connection Duration": "Duración de la conexión",
+"Connection Efficiency": "Eficiencia de conexión",
+"Connection Pool Statistics": "Estadísticas del grupo de conexiones",
+"Connection Timeout": "Tiempo de espera de conexión",
+"Connection timeout (s)": "Tiempos de espera de conexión",
+"Connection timeout in seconds": "Tiempo de espera de conexión en segundos",
+"Connections: {connections} | Packets: {sent}/{received} | Bytes: {bytes_sent}/{bytes_received}": "Conexiones: {conexiones} | Paquetes: {enviados}/{recibidos} | Bytes: {bytes_sent}/{bytes_received}",
+"Connections: {connections}, Signaling: {signaling} ({host}:{port})": "Conexiones: {conexiones}, Señalización: {señalización} ({host}:{puerto})",
+"Controls": "Controles",
+"Copy Info Hash": "Copiar hash de información",
+"Could not connect to daemon (no PID file): %s - will create local session": "No se pudo conectar al demonio (no hay archivo PID): %s - creará una sesión local",
+"Could not find file index": "No se pudo encontrar el índice del archivo",
+"Could not get torrent output directory": "No se pudo obtener el directorio de salida del torrent",
+"Could not load torrent: {path}": "No se pudo cargar torrent: {ruta}",
+"Could not read daemon config from ConfigManager: %s": "No se pudo leer la configuración del demonio desde ConfigManager: %s",
+"Could not save daemon config to config file: %s": "No se pudo guardar la configuración del demonio en el archivo de configuración: %s",
+"Could not send shutdown request, using signal...": "No se pudo enviar la solicitud de apagado, usando la señal...",
+"Count": "Contar",
+"Create Torrent": "Crear torrente",
+"Creating backup...": "Creando copia de seguridad...",
+"Cross-Torrent Sharing": "Compartir entre torrents",
+"Current": "Actual",
+"Current Value": "Valor actual",
+"Current chunks: {count}": "Fragmentos actuales: {count}",
+"Current locale: {locale}": "Ubicación actual: {locale}",
+"DHT Aggressive Mode:": "Modo agresivo DHT:",
+"DHT Health": "Salud DHT",
+"DHT Health (daemon)": "Salud DHT (demonio)",
+"DHT Health Hotspots": "Puntos de acceso de salud DHT",
+"DHT Metrics": "Métricas DHT",
+"DHT Statistics": "Estadísticas de DHT",
+"DHT Status": "Estado de DHT",
+"DHT aggressive mode {status}": "Modo agresivo DHT {status}",
+"DHT client not available. DHT metrics require DHT to be enabled and running.": "Cliente DHT no disponible. Las métricas de DHT requieren que DHT esté habilitado y en ejecución.",
+"DHT data is unavailable in the current mode.": "Los datos DHT no están disponibles en el modo actual.",
+"DHT is not running.": "DHT no se está ejecutando.",
+"DHT is running but no active nodes yet.": "DHT se está ejecutando pero aún no hay nodos activos.",
+"DHT is running. {active} active nodes, {peers} peers found.": "DHT se está ejecutando. {activo} nodos activos, {peers} pares encontrados.",
+"DHT port": "puerto DHT",
+"DHT timeout (s)": "Tiempos de espera DHT",
+"Daemon PID file exists but API key not found (config or daemon config file). Cannot route to daemon. Please check daemon configuration.": "El archivo PID del demonio existe pero no se encontró la clave API (configuración o archivo de configuración del demonio). No se puede enrutar al demonio. Por favor verifique la configuración del demonio.",
+"Daemon PID file exists but cannot connect to daemon (error: {error}).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check if IPC server is running on the configured port\n 3. Verify API key in config matches daemon's API key\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "El archivo PID del demonio existe pero no se puede conectar al demonio (error: {error}).\nEs posible que el demonio se esté iniciando o que se haya bloqueado.\n\nPara resolver:\n 1. Ejecute 'btbt daemon status' para verificar el estado del demonio\n 2. Verifique si el servidor IPC se está ejecutando en el puerto configurado\n 3. Verifique que la clave API en la configuración coincida con la clave API del demonio\n 4. Si el demonio falla, reinícielo: 'btbt daemon start'\n 5. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon PID file exists but cannot connect to daemon: {error}\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but cannot connect to daemon: {error}\n\nPara resolver:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. Si el demonio falla, reinícielo: 'btbt daemon start'\n 4. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon PID file exists but daemon is not accessible after {elapsed:.1f}s.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for startup errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "El archivo PID del demonio existe pero no se puede acceder al demonio después de {elapsed:.1f}.\nEs posible que el demonio se esté iniciando o que se haya bloqueado.\n\nPara resolver:\n 1. Ejecute 'btbt daemon status' para verificar el estado del demonio\n 2. Verifique los registros del demonio para detectar errores de inicio\n 3. Si el demonio falla, reinícielo: 'btbt daemon start'\n 4. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon PID file exists but daemon is not responding (timeout after {elapsed:.1f}s).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "El archivo PID del demonio existe pero el demonio no responde (tiempo de espera después de {elapsed:.1f}s).\nEs posible que el demonio se esté iniciando o que se haya bloqueado.\n\nPara resolver:\n 1. Ejecute 'btbt daemon status' para verificar el estado del demonio\n 2. Verifique los registros del demonio en busca de errores\n 3. Si el demonio falla, reinícielo: 'btbt daemon start'\n 4. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon PID file exists but daemon is not responding after {max_total_wait:.1f}s.\nPossible causes:\n - Daemon is still starting up (wait a few seconds and try again)\n - Daemon crashed (check logs or run 'btbt daemon status')\n - IPC server is not accessible (check firewall/network settings)\n\nTo resolve:\n 1. Run 'btbt daemon status' to check if daemon is actually running\n 2. If daemon is not running, remove stale PID file: 'btbt daemon exit --force'\n 3. If you want to run locally instead, stop the daemon: 'btbt daemon exit'": "El archivo PID del demonio existe pero el demonio no responde después de {max_total_wait:.1f}s.\nPosibles causas:\n - Daemon todavía se está iniciando (espera unos segundos y vuelve a intentarlo)\n - Daemon falló (verifique los registros o ejecute 'btbt daemon status')\n - No se puede acceder al servidor IPC (verifique la configuración del firewall/red)\n\nPara resolver:\n 1. Ejecute 'btbt daemon status' para comprobar si el demonio realmente se está ejecutando.\n 2. Si el demonio no se está ejecutando, elimine el archivo PID obsoleto: 'btbt daemon exit --force'\n 3. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon PID file exists but error occurred while connecting: {error}.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for connection errors\n 3. Verify IPC server is accessible on the configured port\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "El archivo Daemon PID existe pero se produjo un error al conectar: {error}.\nEs posible que el demonio se esté iniciando o que se haya bloqueado.\n\nPara resolver:\n 1. Ejecute 'btbt daemon status' para verificar el estado del demonio\n 2. Verifique los registros del demonio para detectar errores de conexión.\n 3. Verifique que se pueda acceder al servidor IPC en el puerto configurado\n 4. Si el demonio falla, reinícielo: 'btbt daemon start'\n 5. Si desea ejecutar localmente, detenga el demonio: 'btbt daemon exit'",
+"Daemon connection error (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Error de conexión del demonio (intento %d/%d, %.1fs transcurrido): %s, reintentando en %.1fs...",
+"Daemon connection timeout (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Tiempo de espera de conexión del demonio (intento %d/%d, transcurrido %.1fs), reintento en %.1fs...",
+"Daemon connection: config_path=%s, file_exists=%s": "Conexión de demonio: config_path=%s, file_exists=%s",
+"Daemon is accessible and ready (attempt %d/%d, took %.1fs)": "El demonio está accesible y listo (intento %d/%d, tomó %.1fs)",
+"Daemon is marked as running but not accessible (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "El demonio está marcado como en ejecución pero no accesible (intento %d/%d, %.1fs transcurrido), reintentando en %.1fs...",
+"Daemon is marked as running but not accessible after %d attempts (elapsed %.1fs)": "El demonio está marcado como en ejecución pero no se puede acceder a él después de %d intentos (%.1fs transcurridos)",
+"Daemon is not running": "El demonio no se está ejecutando",
+"Daemon is not running, nothing to restart": "Daemon no se está ejecutando, no hay nada que reiniciar",
+"Daemon is not running, restart not needed": "Daemon no se está ejecutando, no es necesario reiniciar",
+"Daemon is not running. File management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon no se está ejecutando. Los comandos de administración de archivos requieren que el demonio esté en ejecución.\nInicie el demonio con: 'btbt daemon start'",
+"Daemon is not running. NAT management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon no se está ejecutando. Los comandos de administración de NAT requieren que el demonio esté en ejecución.\nInicie el demonio con: 'btbt daemon start'",
+"Daemon is not running. Queue management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon no se está ejecutando. Los comandos de gestión de colas requieren que el demonio esté en ejecución.\nInicie el demonio con: 'btbt daemon start'",
+"Daemon is not running. Scrape commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon no se está ejecutando. Los comandos de scrape requieren que el demonio esté ejecutándose.\nInicie el demonio con: 'btbt daemon start'",
+"Daemon restarted successfully (PID: %d)": "El demonio se reinició exitosamente (PID: %d)",
+"Daemon stopped": "demonio se detuvo",
+"Daemon stopped gracefully": "Daemon se detuvo con gracia",
+"Dark": "Oscuro",
+"Dark Mode": "Modo oscuro",
+"Dashboard Error": "Error del panel",
+"Data": "Datos",
+"Data provider or command executor not available": "Proveedor de datos o ejecutor de comandos no disponible",
+"Default": "Por defecto",
+"Default (Light)": "Predeterminado (claro)",
+"Delete torrent {info_hash}…? Press 'y' to confirm or 'n' to cancel": "¿Eliminar torrent {info_hash}…? Presione 'y' para confirmar o 'n' para cancelar",
+"Depth": "Profundidad",
+"Description: {desc}": "Descripción: {desc}",
+"Deselect All": "Deseleccionar todo",
+"Deselect folder": "Deseleccionar carpeta",
+"Deselected {count} file(s)": "{count} archivo(s) no seleccionados",
+"Diff written to {path}": "Diferencia escrita en {ruta}",
+"Direct session access not available in daemon mode": "El acceso directo a la sesión no está disponible en modo demonio",
+"Disable DHT": "Desactivar DHT",
+"Disable HTTP trackers": "Deshabilitar rastreadores HTTP",
+"Disable IPv6": "Deshabilitar IPv6",
+"Disable Protocol v2 (BEP 52)": "Deshabilitar protocolo v2 (BEP 52)",
+"Disable TCP transport": "Deshabilitar el transporte TCP",
+"Disable TCP_NODELAY": "Deshabilitar TCP_NODELAY",
+"Disable UDP trackers": "Deshabilitar rastreadores UDP",
+"Disable checkpointing": "Desactivar puntos de control",
+"Disable io_uring usage": "Deshabilitar el uso de io_uring",
+"Disable memory mapping": "Deshabilitar el mapeo de memoria",
+"Disable metrics": "Deshabilitar métricas",
+"Disable protocol encryption": "Deshabilitar el cifrado de protocolo",
+"Disable sparse files": "Deshabilitar archivos dispersos",
+"Disable splash screen (useful for debugging)": "Deshabilitar la pantalla de presentación (útil para depurar)",
+"Disable uTP transport": "Deshabilitar el transporte uTP",
+"Disk": "Disco",
+"Disk I/O Configuration": "Configuración de E/S de disco",
+"Disk I/O Statistics": "Estadísticas de E/S de disco",
+"Disk I/O configuration (preallocation, hashing, checkpoints)": "Configuración de E/S de disco (preasignación, hash, puntos de control)",
+"Disk I/O metrics - Error: {error}": "Métricas de E/S de disco: error: {error}",
+"Disk I/O workers": "Trabajadores de E/S de disco",
+"Disk IO": "E/S de disco",
+"Disk Workers": "Trabajadores de disco",
+"Do Not Download": "No descargar",
+"Down (B/s)": "Abajo (B/s)",
+"Down/Up (B/s)": "Abajo/Arriba (B/s)",
+"Download Limit": "Límite de descarga",
+"Download Limit (KiB/s):": "Límite de descarga (KiB/s):",
+"Download Rate": "Tarifa de descarga",
+"Download Rate Limit (bytes/sec, 0 = unlimited):": "Límite de velocidad de descarga (bytes/seg, 0 = ilimitado):",
+"Download Trend": "Descargar Tendencia",
+"Download cancelled{checkpoint_info}": "Descarga cancelada{checkpoint_info}",
+"Download force started": "Descarga forzada iniciada",
+"Download limit (KiB/s, 0 = unlimited)": "Límite de descarga (KiB/s, 0 = ilimitado)",
+"Download paused{checkpoint_info}": "Descarga pausada{checkpoint_info}",
+"Download resumed{checkpoint_info}": "Descarga reanudada{checkpoint_info}",
+"Download swing {delta:.1f} KiB/s (peak {peak:.1f} KiB/s)": "Descarga oscilación {delta:.1f} KiB/s (pico {pico:.1f} KiB/s)",
+"Download:": "Descargar:",
+"Downloaders": "Descargadores",
+"Downloading": "Descargando",
+"Dracula": "Drácula",
+"Duplicate Requests Prevented": "Solicitudes duplicadas evitadas",
+"Duration": "Duración",
+"Editing: {section}": "Edición: {sección}",
+"Enable Compression:": "Habilitar compresión:",
+"Enable DHT": "Habilitar DHT",
+"Enable Deduplication:": "Habilitar la deduplicación:",
+"Enable HTTP trackers": "Habilitar rastreadores HTTP",
+"Enable IPFS Protocol:": "Habilite el protocolo IPFS:",
+"Enable IPv6": "Habilitar IPv6",
+"Enable NAT Port Mapping:": "Habilite la asignación de puertos NAT:",
+"Enable P2P Content-Addressed Storage:": "Habilite el almacenamiento dirigido a contenido P2P:",
+"Enable Protocol v2 (BEP 52)": "Habilitar protocolo v2 (BEP 52)",
+"Enable TCP transport": "Habilitar el transporte TCP",
+"Enable TCP_NODELAY": "Habilitar TCP_NODELAY",
+"Enable UDP trackers": "Habilitar rastreadores UDP",
+"Enable Xet Protocol:": "Habilitar el protocolo Xet:",
+"Enable debug mode (deprecated, use -vv)": "Habilitar el modo de depuración (en desuso, use -vv)",
+"Enable debug verbosity (equivalent to -vv)": "Habilitar la detalle de depuración (equivalente a -vv)",
+"Enable direct I/O for writes when supported": "Habilite la E/S directa para escrituras cuando sea compatible",
+"Enable fsync after batched writes": "Habilite fsync después de escrituras por lotes",
+"Enable io_uring on Linux if available": "Habilite io_uring en Linux si está disponible",
+"Enable metrics": "Habilitar métricas",
+"Enable monitoring": "Habilitar monitoreo",
+"Enable protocol encryption": "Habilitar el cifrado de protocolo",
+"Enable sparse files": "Habilitar archivos dispersos",
+"Enable streaming mode": "Habilitar el modo de transmisión",
+"Enable trace verbosity (equivalent to -vvv)": "Habilitar la detalle del seguimiento (equivalente a -vvv)",
+"Enable uTP Transport:": "Habilitar el transporte uTP:",
+"Enable uTP transport": "Habilitar el transporte uTP",
+"Enabled (Dependency Missing)": "Habilitado (falta dependencia)",
+"Enabled (Not Started)": "Habilitado (no iniciado)",
+"Encrypt backup with generated key": "Cifrar la copia de seguridad con la clave generada",
+"Encrypting backup...": "Cifrando copia de seguridad...",
+"Endgame duplicate requests": "Solicitudes duplicadas de final de juego",
+"Endgame threshold (0..1)": "Umbral de final de juego (0..1)",
+"Enter Tracker URL": "Ingrese la URL del rastreador",
+"Enter path...": "Introduzca la ruta...",
+"Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.": "Ingrese al directorio donde se deben descargar los archivos:\n\nDéjelo vacío para usar el directorio actual.",
+"Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...": "Ingrese la ruta a un archivo .torrent o un enlace magnético:\n\nEjemplos:\n /ruta/al/archivo.torrent\n imán:?xt=urna:btih:...",
+"Enter torrent file path or magnet link": "Ingrese la ruta del archivo torrent o el enlace magnético",
+"Enter torrent file path or magnet link:": "Ingrese la ruta del archivo torrent o el enlace magnético:",
+"Error": "[ES] Error",
+"Error adding tracker: {error}": "Error al agregar el rastreador: {error}",
+"Error banning peer: {error}": "Error al prohibir el par: {error}",
+"Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Error al comprobar la accesibilidad del demonio (intento %d/%d, %.1fs transcurrido): %s, reintentando en %.1fs...",
+"Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s": "Error al comprobar la accesibilidad del demonio después de %d intentos (%.1fs transcurridos): %s",
+"Error checking daemon stage: %s": "Error al comprobar la etapa del demonio: %s",
+"Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection": "Error al comprobar si el demonio se está ejecutando (¿problema específico de Windows?): %s: el archivo PID existe, intentará la conexión IPC",
+"Error checking if restart is needed: %s": "Error al comprobar si es necesario reiniciar: %s",
+"Error closing HTTP session: %s": "Error al cerrar la sesión HTTP: %s",
+"Error closing IPC client: %s": "Error al cerrar el cliente IPC: %s",
+"Error closing WebSocket: %s": "Error al cerrar WebSocket: %s",
+"Error comparing configs: {e}": "Error al comparar configuraciones: {e}",
+"Error creating backup: {e}": "Error al crear la copia de seguridad: {e}",
+"Error creating torrent": "Error al crear torrent",
+"Error deselecting files: {error}": "Error al anular la selección de archivos: {error}",
+"Error executing config.get command: {error}": "Error al ejecutar el comando config.get: {error}",
+"Error executing {operation} on daemon: {error}": "Error al ejecutar {operación} en el demonio: {error}",
+"Error exporting configuration: {e}": "Error al exportar la configuración: {e}",
+"Error forcing announce: {error}": "Error al forzar el anuncio: {error}",
+"Error generating schema: {e}": "Error al generar el esquema: {e}",
+"Error getting DHT stats: {error}": "Error al obtener estadísticas de DHT: {error}",
+"Error getting daemon status": "Error al obtener el estado del demonio",
+"Error getting daemon status: %s": "Error al obtener el estado del demonio: %s",
+"Error importing configuration: {e}": "Error al importar la configuración: {e}",
+"Error in socket pre-check: %s": "Error en la verificación previa del socket: %s",
+"Error listing backups: {e}": "Error al enumerar las copias de seguridad: {e}",
+"Error listing profiles: {e}": "Error al enumerar perfiles: {e}",
+"Error listing templates: {e}": "Plantillas de listado de error: {e}",
+"Error loading DHT data: {error}": "Error al cargar datos DHT: {error}",
+"Error loading DHT summary: {error}": "Error al cargar el resumen DHT: {error}",
+"Error loading configuration: {error}": "Error al cargar la configuración: {error}",
+"Error loading info: {error}": "Error al cargar información: {error}",
+"Error loading peer data: {error}": "Error al cargar datos de pares: {error}",
+"Error loading section: {error}": "Error al cargar la sección: {error}",
+"Error loading security data: {error}": "Error al cargar datos de seguridad: {error}",
+"Error loading torrent config: {error}": "Error al cargar la configuración de torrent: {error}",
+"Error loading torrent: {error}": "Error al cargar torrent: {error}",
+"Error opening folder: {error}": "Error al abrir la carpeta: {error}",
+"Error processing file %s: %s": "Error al procesar el archivo %s: %s",
+"Error reading PID file after retries: %s": "Error al leer el archivo PID después de reintentos: %s",
+"Error reading PID file: %s": "Error al leer el archivo PID: %s",
+"Error receiving WebSocket event: %s": "Error al recibir el evento WebSocket: %s",
+"Error receiving WebSocket events batch: %s": "Error al recibir el lote de eventos de WebSocket: %s",
+"Error removing tracker: {error}": "Error al eliminar el rastreador: {error}",
+"Error restarting daemon": "Error al reiniciar el demonio",
+"Error restoring backup: {e}": "Error al restaurar la copia de seguridad: {e}",
+"Error routing to daemon (PID file exists): %s": "Error de enrutamiento al demonio (el archivo PID existe): %s",
+"Error routing to daemon (no PID file): %s - will create local session": "Error de enrutamiento al demonio (sin archivo PID): %s: creará una sesión local",
+"Error saving configuration: {error}": "Error al guardar la configuración: {error}",
+"Error selecting files: {error}": "Error al seleccionar archivos: {error}",
+"Error sending shutdown request: %s": "Error al enviar la solicitud de cierre: %s",
+"Error setting DHT aggressive mode: {error}": "Error al configurar el modo agresivo DHT: {error}",
+"Error setting file priority: {error}": "Error al configurar la prioridad del archivo: {error}",
+"Error starting daemon": "Error al iniciar el demonio",
+"Error stopping daemon": "Error al detener el demonio",
+"Error stopping session: %s": "Error al detener la sesión: %s",
+"Error submitting form: {error}": "Error al enviar el formulario: {error}",
+"Error verifying files: {error}": "Error al verificar archivos: {error}",
+"Error waiting for daemon with progress: %s": "Error esperando demonio con progreso: %s",
+"Error waiting for daemon: %s": "Error esperando al demonio: %s",
+"Error waiting for metadata: %s": "Error esperando metadatos: %s",
+"Error with auto-tuning: {e}": "Error con el autoajuste: {e}",
+"Error with profile: {e}": "Error con el perfil: {e}",
+"Error with template: {e}": "Error con la plantilla: {e}",
+"Error: {error}": "Error : {error}",
+"Errors": "Errores",
+"Estimated Read Speed": "Velocidad de lectura estimada",
+"Estimated Write Speed": "Velocidad de escritura estimada",
+"Events": "Eventos",
+"Eviction rate: {rate:.2f} /sec": "Tasa de desalojo: {rate:.2f} /seg",
+"Exceeded maximum wait time (%.1fs) for daemon readiness": "Se superó el tiempo de espera máximo (%.1fs) para la preparación del demonio",
+"Excellent": "Excelente",
+"Exists": "existe",
+"Expected info hash (hex)": "Hash de información esperada (hexadecimal)",
+"Expected type: {type_name}": "Tipo esperado: {type_name}",
+"Export complete": "Exportación completa",
+"Exporting checkpoint...": "Exportando punto de control...",
+"Failed Requests": "Solicitudes fallidas",
+"Failed to add content": "No se pudo agregar contenido",
+"Failed to add magnet link": "No se pudo agregar el enlace magnético",
+"Failed to add peer to allowlist": "No se pudo agregar un par a la lista de permitidos",
+"Failed to add to queue": "No se pudo agregar a la cola",
+"Failed to add torrent": "No se pudo agregar torrent",
+"Failed to add torrent to daemon": "No se pudo agregar torrent al demonio",
+"Failed to add tracker": "No se pudo agregar el rastreador",
+"Failed to add tracker: {error}": "No se pudo agregar el rastreador: {error}",
+"Failed to announce: {error}": "No se pudo anunciar: {error}",
+"Failed to ban peer: {error}": "No se pudo prohibir el par: {error}",
+"Failed to calculate progress: %s": "No se pudo calcular el progreso: %s",
+"Failed to cancel torrent": "No se pudo cancelar el torrent",
+"Failed to cleanup Xet cache": "No se pudo limpiar el caché de Xet",
+"Failed to clear queue": "No se pudo borrar la cola",
+"Failed to collect custom metrics: %s": "No se pudieron recopilar métricas personalizadas: %s",
+"Failed to collect performance metrics: %s": "No se pudieron recopilar métricas de rendimiento: %s",
+"Failed to collect system metrics: %s": "No se pudieron recopilar las métricas del sistema: %s",
+"Failed to copy info hash: {error}": "No se pudo copiar el hash de información: {error}",
+"Failed to deselect all files": "No se pudo anular la selección de todos los archivos",
+"Failed to deselect files": "No se pudieron anular la selección de archivos",
+"Failed to deselect files: {error}": "No se pudieron anular la selección de archivos: {error}",
+"Failed to disable io_uring: %s": "No se pudo deshabilitar io_uring: %s",
+"Failed to discover NAT": "No se pudo descubrir NAT",
+"Failed to enable io_uring: %s": "No se pudo habilitar io_uring: %s",
+"Failed to force start all torrents": "No se pudo forzar el inicio de todos los torrents",
+"Failed to force start torrent": "No se pudo forzar el inicio del torrent",
+"Failed to generate .tonic file": "No se pudo generar el archivo .tonic",
+"Failed to generate tonic link": "No se pudo generar el enlace tónico",
+"Failed to get NAT status": "No se pudo obtener el estado NAT",
+"Failed to get Xet cache info": "No se pudo obtener la información del caché de Xet",
+"Failed to get Xet stats": "No se pudieron obtener las estadísticas de Xet",
+"Failed to get config: {error}": "No se pudo obtener la configuración: {error}",
+"Failed to get content": "No se pudo obtener el contenido",
+"Failed to get metrics interval from config: %s": "No se pudo obtener el intervalo de métricas de la configuración: %s",
+"Failed to get peers": "No se pudo conseguir compañeros",
+"Failed to get per-peer rate limit": "No se pudo obtener el límite de tasa por par",
+"Failed to get queue": "No se pudo obtener la cola",
+"Failed to get stats": "No se pudieron obtener estadísticas",
+"Failed to get sync mode": "No se pudo obtener el modo de sincronización",
+"Failed to get sync status": "No se pudo obtener el estado de sincronización",
+"Failed to launch media player": "No se pudo iniciar el reproductor multimedia",
+"Failed to list aliases": "No se pudieron enumerar los alias",
+"Failed to list allowlist": "No se pudo incluir la lista de permitidos",
+"Failed to list files": "No se pudieron enumerar los archivos",
+"Failed to list scrape results": "No se pudieron enumerar los resultados del scrape",
+"Failed to load DHT health data: {error}": "No se pudieron cargar los datos de salud de DHT: {error}",
+"Failed to load filter file: {file_path}": "No se pudo cargar el archivo de filtro: {file_path}",
+"Failed to load global KPIs: {error}": "No se pudieron cargar los KPI globales: {error}",
+"Failed to load peer quality distribution: {error}": "No se pudo cargar la distribución de calidad de pares: {error}",
+"Failed to load piece selection metrics: {error}": "No se pudieron cargar las métricas de selección de piezas: {error}",
+"Failed to load swarm timeline: {error}": "No se pudo cargar la línea de tiempo del enjambre: {error}",
+"Failed to map port": "No se pudo asignar el puerto",
+"Failed to move in queue": "No se pudo mover en la cola",
+"Failed to parse config value: %s": "No se pudo analizar el valor de configuración: %s",
+"Failed to pause all torrents": "No se pudieron pausar todos los torrents",
+"Failed to pause torrent": "No se pudo pausar el torrent",
+"Failed to pin content": "No se pudo fijar el contenido",
+"Failed to refresh PEX": "No se pudo actualizar PEX",
+"Failed to refresh checkpoint": "No se pudo actualizar el punto de control",
+"Failed to refresh mappings": "No se pudieron actualizar las asignaciones",
+"Failed to refresh media state: {error}": "No se pudo actualizar el estado de los medios: {error}",
+"Failed to reload checkpoint": "No se pudo recargar el punto de control",
+"Failed to remove alias": "No se pudo eliminar el alias",
+"Failed to remove from queue": "No se pudo eliminar de la cola",
+"Failed to remove peer from allowlist": "No se pudo eliminar el par de la lista de permitidos",
+"Failed to remove tracker": "No se pudo eliminar el rastreador",
+"Failed to remove tracker: {error}": "No se pudo eliminar el rastreador: {error}",
+"Failed to resume all torrents": "No se pudieron reanudar todos los torrents",
+"Failed to resume torrent": "No se pudo reanudar el torrent",
+"Failed to save config: {error}": "No se pudo guardar la configuración: {error}",
+"Failed to save configuration to file: %s": "No se pudo guardar la configuración en el archivo: %s",
+"Failed to scrape torrent": "No se pudo extraer el torrent",
+"Failed to select all files": "No se pudieron seleccionar todos los archivos",
+"Failed to select files": "No se pudieron seleccionar archivos",
+"Failed to select files: {error}": "No se pudieron seleccionar archivos: {error}",
+"Failed to set DHT aggressive mode": "No se pudo configurar el modo agresivo DHT",
+"Failed to set DHT aggressive mode: {error}": "No se pudo configurar el modo agresivo DHT: {error}",
+"Failed to set alias": "No se pudo establecer el alias",
+"Failed to set all peers rate limits": "No se pudieron establecer los límites de tarifas de todos los pares",
+"Failed to set file priority": "No se pudo establecer la prioridad del archivo",
+"Failed to set first piece priority: %s": "No se pudo establecer la prioridad de la primera pieza: %s",
+"Failed to set last piece priority: %s": "No se pudo establecer la prioridad de la última pieza: %s",
+"Failed to set per-peer rate limit": "No se pudo establecer el límite de tasa por par",
+"Failed to set priority": "No se pudo establecer la prioridad",
+"Failed to set priority: {error}": "No se pudo establecer la prioridad: {error}",
+"Failed to set sync mode": "No se pudo configurar el modo de sincronización",
+"Failed to share folder": "No se pudo compartir la carpeta",
+"Failed to sign WebSocket request: %s": "No se pudo firmar la solicitud de WebSocket: %s",
+"Failed to sign request with Ed25519: %s": "No se pudo firmar la solicitud con Ed25519: %s",
+"Failed to start media stream": "No se pudo iniciar la transmisión multimedia",
+"Failed to start sync": "No se pudo iniciar la sincronización",
+"Failed to stop daemon": "No se pudo detener el demonio",
+"Failed to stop media stream": "No se pudo detener la transmisión multimedia",
+"Failed to unmap port": "No se pudo desasignar el puerto",
+"Failed to unpin content": "No se pudo desanclar el contenido",
+"Fair": "Justo",
+"Fetching Metadata...": "Obteniendo metadatos...",
+"Fetching file list for selection. This may take a moment.": "Obteniendo lista de archivos para selección. Esto puede tardar un momento.",
+"Field": "Campo",
+"File Browser": "Explorador de archivos",
+"File Browser - Data provider or executor not available": "Explorador de archivos: proveedor de datos o ejecutor no disponible",
+"File Browser - Error: {error}": "Explorador de archivos - Error: {error}",
+"File Browser - Select files to create torrents": "Explorador de archivos: seleccione archivos para crear torrents",
+"File Explorer": "Explorador de archivos",
+"File must have .torrent extension: %s": "El archivo debe tener extensión .torrent: %s",
+"File not found: %s": "Archivo no encontrado: %s",
+"File {number}": "Archivo {número}",
+"File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}": "Archivo: {nombre}\nPuerto: {puerto}\nBytes servidos: {bytes_served}\nClientes: {clientes}\nÚltimo rango: {inicio} - {fin}\nBytes legibles: {disponibles}\nÚltimo error: {error}",
+"Files in torrent {hash}...": "Archivos en torrent {hash}....",
+"Files: {count}": "Archivos: {count}",
+"Filter update failed": "Error al actualizar el filtro",
+"Folder not found: {folder}": "Carpeta no encontrada: {carpeta}",
+"Folder: {name}": "Carpeta: {nombre}",
+"Force Announce": "Anuncio de fuerza",
+"Force kill without graceful shutdown": "Forzar la muerte sin un apagado elegante",
+"Found {count} potential issues": "Se encontraron {count} problemas potenciales",
+"Full Path": "Camino completo",
+"Full configuration editing requires navigating to the Global Config screen": "La edición completa de la configuración requiere navegar a la pantalla de configuración global",
+"General": "Aspectos generales",
+"General configuration - Data provider/Executor not available": "Configuración general: proveedor de datos/ejecutor no disponible",
+"Generate new API key": "Generar nueva clave API",
+"Generated new API key for daemon": "Nueva clave API generada para el demonio",
+"Generating {format} torrent...": "Generando {formato} torrent...",
+"GitHub Dark": "GitHub oscuro",
+"Global": "[ES] Global",
+"Global Configuration": "Configuración global",
+"Global Connected Peers": "Pares conectados globales",
+"Global KPIs": "KPI globales",
+"Global KPIs data is unavailable in the current mode.": "Los datos de KPI globales no están disponibles en el modo actual.",
+"Global Key Performance Indicators": "Indicadores clave de desempeño globales",
+"Global Torrent Metrics": "Métricas globales de torrents",
+"Global config": "Configuración global",
+"Global download limit (KiB/s)": "Límite de descarga global (KiB/s)",
+"Global upload limit (KiB/s)": "Límite de carga global (KiB/s)",
+"Good": "Bien",
+"Graceful shutdown timeout, forcing stop": "Tiempo de espera de apagado elegante, forzando la parada",
+"Graphs": "Graficos",
+"Gruvbox": "[ES] Gruvbox",
+"HTTP error checking daemon status at %s: %s (status %d)": "Error HTTP al comprobar el estado del demonio en %s: %s (estado %d)",
+"Hash Chunk Size": "Tamaño del fragmento de hash",
+"Hash verification workers": "Trabajadores de verificación de hash",
+"Health": "Salud",
+"Help screen": "Pantalla de ayuda",
+"High": "Alto",
+"Historical trends": "Tendencias históricas",
+"Host for web interface": "Anfitrión para interfaz web",
+"IP Address": "Dirección IP",
+"IP filter not available": "Filtro IP no disponible",
+"IP:Port": "IP: Puerto",
+"IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)": "IPCClient.get_daemon_pid: Comprobando pid_file=%s (home_dir=%s, existe=%s)",
+"IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.": "Opciones del protocolo IPFS:\n\nIPFS permite el almacenamiento de contenido dirigido y el intercambio de contenido de igual a igual.\nSe puede acceder al contenido a través de IPFS CID después de la descarga.",
+"IPFS management": "Gestión de IPFS",
+"Idle": "Inactivo",
+"Inactive": "Inactivo",
+"Include effective runtime value from loaded config (file + env)": "Incluya el valor de tiempo de ejecución efectivo de la configuración cargada (archivo + entorno)",
+"Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)": "Aumentar la detalle (-v: detallado, -vv: depuración, -vvv: seguimiento)",
+"Index": "Índice",
+"Info": "Información",
+"Info Hashes": "Hash de información",
+"Info hash copied to clipboard": "Hash de información copiado al portapapeles",
+"Info hash: {hash}": "Hash de información: {hash}",
+"Initial Rate": "Tarifa inicial",
+"Initial send rate": "Tasa de envío inicial",
+"Invalid IP address: {error}": "Dirección IP no válida: {error}",
+"Invalid IP range: {ip_range}": "Rango de IP no válido: {ip_range}",
+"Invalid configuration after merge: {e}": "Configuración no válida después de la fusión: {e}",
+"Invalid configuration: top-level must be an object": "Configuración no válida: el nivel superior debe ser un objeto",
+"Invalid configuration: {e}": "Configuración no válida: {e}",
+"Invalid info hash format": "Formato hash de información no válido",
+"Invalid info hash format: %s": "Formato hash de información no válido: %s",
+"Invalid info hash format: {hash}": "Formato hash de información no válido: {hash}",
+"Invalid info hash length in magnet link": "Longitud del hash de información no válida en el enlace magnético",
+"Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu": "Se ha especificado la configuración regional '{current_locale}' no válida. Volviendo a 'en'. Idiomas disponibles: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu",
+"Invalid magnet link - missing 'xt=urn:btih:' parameter": "Enlace magnético no válido: falta el parámetro 'xt=urn:btih:'",
+"Invalid magnet link format": "Formato de enlace magnético no válido",
+"Invalid magnet link format - must start with 'magnet:?'": "Formato de enlace magnético no válido: debe comenzar con 'imán:?'",
+"Invalid peer selection": "Selección de pares no válida",
+"Invalid profile '{name}': {errors}": "Perfil no válido '{nombre}': {errores}",
+"Invalid template '{name}': {errors}": "Plantilla no válida '{nombre}': {errores}",
+"Invalid tracker URL format. Must start with http://, https://, or udp://": "Formato de URL de seguimiento no válido. Debe comenzar con http://, https:// o udp://",
+"Invalid tracker selection": "Selección de rastreador no válida",
+"Key Bindings": "Atajos de teclas",
+"Language": "Idioma",
+"Last Error": "Último error",
+"Last Update": "Última actualización",
+"Last sample {age}": "Última muestra {edad}",
+"Latency": "Estado latente",
+"Light": "Luz",
+"Light Mode": "Modo de luz",
+"List available locales": "Listar configuraciones regionales disponibles",
+"Listen interface": "Interfaz de escucha",
+"Listen port": "Puerto de escucha",
+"Loading configuration...": "Cargando configuración...",
+"Loading file list…": "Cargando lista de archivos…",
+"Loading peer metrics...": "Cargando métricas de pares...",
+"Loading piece selection metrics...": "Cargando métricas de selección de piezas...",
+"Loading swarm timeline...": "Cargando línea de tiempo de enjambre...",
+"Loading torrent information...": "Cargando información del torrent...",
+"Local Node Information": "Información del nodo local",
+"Low": "Bajo",
+"MMap cache size (MB)": "Tamaño de caché de MMap (MB)",
+"MTU": "[ES] MTU",
+"Magnet command: PID file check - exists=%s, path=%s": "Comando magnético: verificación del archivo PID: existe=%s, ruta=%s",
+"Magnet link must contain 'xt=urn:btih:' parameter": "El enlace magnético debe contener el parámetro 'xt=urn:btih:'",
+"Magnet link must start with 'magnet:?'": "El enlace magnético debe comenzar con 'imán:?'",
+"Max Rate": "Tarifa máxima",
+"Max Retransmits": "Retransmisiones máximas",
+"Max Window Size": "Tamaño máximo de ventana",
+"Maximum": "Máximo",
+"Maximum UDP packet size": "Tamaño máximo de paquete UDP",
+"Maximum block size (KiB)": "Tamaño máximo de bloque (KiB)",
+"Maximum download rate for this torrent": "Tasa máxima de descarga para este torrent",
+"Maximum global peers": "Máximo de pares globales",
+"Maximum peers per torrent": "Máximo de pares por torrent",
+"Maximum receive window size": "Tamaño máximo de ventana de recepción",
+"Maximum retransmission attempts": "Intentos máximos de retransmisión",
+"Maximum send rate": "Velocidad máxima de envío",
+"Maximum upload rate for this torrent": "Tasa de carga máxima para este torrent",
+"Media": "Medios de comunicación",
+"Media Playback": "Reproducción multimedia",
+"Media stream started.": "Se inició la transmisión de medios.",
+"Media stream stopped.": "La transmisión multimedia se detuvo.",
+"Medium": "Medio",
+"Memory": "Memoria",
+"Metadata is loading. File selection will appear when available.": "Los metadatos se están cargando. La selección de archivos aparecerá cuando esté disponible.",
+"Metrics explorer": "Explorador de métricas",
+"Metrics interval (s)": "Intervalo(s) de métricas",
+"Metrics interval: {interval}s": "Intervalo de métricas: {interval}s",
+"Metrics port": "Puerto de métricas",
+"Migrating checkpoint format from {from_fmt} to {to_fmt}...": "Migrando el formato del punto de control de {from_fmt} a {to_fmt}....",
+"Migration complete": "Migración completa",
+"Min Rate": "Tarifa mínima",
+"Minimum block size (KiB)": "Tamaño mínimo de bloque (KiB)",
+"Minimum send rate": "Tasa de envío mínima",
+"Mode": "Modo",
+"Model '{model}' not found in Config": "Modelo '{model}' no encontrado en la configuración",
+"Modified": "Modificado",
+"Monitoring": "Escucha",
+"Monokai": "monokai",
+"N/A": "N / A",
+"NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.": "Opciones de recorrido NAT:\n\nEl recorrido NAT (NAT-PMP/UPnP) asigna automáticamente los puertos de su enrutador.\nEsto permite que los compañeros se conecten contigo directamente, mejorando las velocidades de descarga.",
+"NAT management": "gestión NAT",
+"Name: {name}": "Nombre: {nombre}",
+"Navigation": "Navegación",
+"Navigation menu": "Menú de navegación",
+"Network Configuration": "Configuración de red",
+"Network Optimization Recommendations": "Recomendaciones de optimización de red",
+"Network Performance": "Rendimiento de la red",
+"Network configuration (connections, timeouts, rate limits)": "Configuración de red (conexiones, tiempos de espera, límites de velocidad)",
+"Network configuration - Data provider/Executor not available": "Configuración de red: proveedor de datos/ejecutor no disponible",
+"Network quality": "Calidad de la red",
+"Network quality - Error: {error}": "Calidad de la red - Error: {error}",
+"Never": "Nunca",
+"Next": "Próximo",
+"Next Step": "Siguiente paso",
+"No DHT metrics per torrent yet.": "Aún no hay métricas de DHT por torrent.",
+"No PID file found, checking for daemon via _get_executor()": "No se encontró ningún archivo PID, buscando demonio a través de _get_executor()",
+"No access": "Sin acceso",
+"No active stream to stop.": "No hay transmisión activa para detener.",
+"No availability data": "Sin datos de disponibilidad",
+"No checkpoint found": "No se encontró ningún punto de control",
+"No commands available": "No hay comandos disponibles",
+"No configuration file to backup": "No hay archivo de configuración para respaldar",
+"No daemon PID file found - daemon is not running": "No se encontró ningún archivo PID del demonio: el demonio no se está ejecutando",
+"No daemon detected (PID file doesn't exist), creating local session. PID file path: %s": "No se detectó ningún demonio (el archivo PID no existe), creando una sesión local. Ruta del archivo PID: %s",
+"No file selected": "Ningún archivo seleccionado",
+"No files to deselect": "No hay archivos para deseleccionar",
+"No files to select": "No hay archivos para seleccionar",
+"No locales directory found": "No se encontró ningún directorio local",
+"No magnet URI provided": "No se proporcionó ningún URI magnético",
+"No magnet URI provided for add_magnet operation.": "No se proporcionó ningún URI magnético para la operación add_magnet.",
+"No metrics available": "No hay métricas disponibles",
+"No peer quality data available": "No hay datos de calidad de pares disponibles",
+"No peer selected": "Ningún compañero seleccionado",
+"No peers available": "No hay compañeros disponibles",
+"No per-torrent data available": "No hay datos por torrent disponibles",
+"No pieces": "Sin piezas",
+"No playable files": "No hay archivos reproducibles",
+"No playable media files were detected for this torrent.": "No se detectaron archivos multimedia reproducibles para este torrent.",
+"No recent security events.": "No hay eventos de seguridad recientes.",
+"No section selected for editing": "No se ha seleccionado ninguna sección para editar",
+"No significant events detected.": "No se detectaron eventos significativos.",
+"No swarm activity captured for the selected window.": "No se capturó ninguna actividad de enjambre para la ventana seleccionada.",
+"No swarm samples": "Sin muestras de enjambre",
+"No torrent data loaded. Please go back to step 1.": "No se cargaron datos de torrent. Por favor regrese al paso 1.",
+"No torrent path or magnet provided": "No se proporciona ruta de torrent ni imán",
+"No torrent path or magnet provided for add_torrent operation.": "No se proporciona ninguna ruta de torrent ni imán para la operación add_torrent.",
+"No torrents with DHT activity yet.": "Aún no hay torrents con actividad DHT.",
+"No torrents yet. Use 'add' to start downloading.": "Aún no hay torrentes. Utilice 'agregar' para comenzar a descargar.",
+"No tracker selected": "No se seleccionó ningún rastreador",
+"No trackers found": "No se encontraron rastreadores",
+"Node ID": "ID de nodo",
+"Node Information": "Información del nodo",
+"Node information not available.": "La información del nodo no está disponible.",
+"Nodes/Q": "Nodos/Q",
+"Non-Empty Buckets": "Baldes no vacíos",
+"Nord": "Norte",
+"Normal": "[ES] Normal",
+"Not enabled": "No habilitado",
+"Not enabled in configuration": "No habilitado en la configuración",
+"Not initialized": "No inicializado",
+"Note": "Nota",
+"Number of pieces to verify for integrity (0 = disable)": "Número de piezas para verificar la integridad (0 = desactivar)",
+"OK (dry-run — configuration is valid)": "OK (ejecución en seco: la configuración es válida)",
+"OK (dry-run — merged configuration is valid)": "OK (ejecución en seco: la configuración fusionada es válida)",
+"One Dark": "uno oscuro",
+"Only options in this top-level section (e.g. network)": "Sólo opciones en esta sección de nivel superior (por ejemplo, red)",
+"Only paths starting with this prefix": "Sólo rutas que comienzan con este prefijo",
+"Open File": "Abrir archivo",
+"Open Folder": "Abrir carpeta",
+"Open in VLC": "Abrir en VLC",
+"Opened folder: {path}": "Carpeta abierta: {ruta}",
+"Opened stream in external player via {method}.": "Transmisión abierta en un reproductor externo mediante {método}.",
+"Optimistic unchoke interval (s)": "Intervalo(s) de liberación optimista",
+"Option": "Opción",
+"Others can join with: ccbt tonic sync \"{link}\" --output ": "Otros pueden unirse con: ccbt tonic sync \"{link}\" --output ",
+"Output Directory": "Directorio de salida",
+"Output directory": "Directorio de salida",
+"Output directory (default: current directory)": "Directorio de salida (predeterminado: directorio actual)",
+"Output directory not available": "Directorio de salida no disponible",
+"Output file path": "Ruta del archivo de salida",
+"Output format for the option catalog": "Formato de salida para el catálogo de opciones.",
+"Overall Efficiency": "Eficiencia general",
+"Overall Health": "Salud general",
+"Override IPC server port": "Anular el puerto del servidor IPC",
+"PEX interval (s)": "Intervalo(s) PEX",
+"PEX refresh failed: {error}": "Error en la actualización de PEX: {error}",
+"PEX refresh requested": "Actualización PEX solicitada",
+"PEX: Failed": "PEX: fallido",
+"PID file contains invalid PID: %d, removing": "El archivo PID contiene un PID no válido: %d, eliminando",
+"PID file contains invalid data: %r, removing": "El archivo PID contiene datos no válidos: %r, eliminando",
+"PID file is empty, removing": "El archivo PID está vacío, eliminándolo",
+"Parsing files and building file tree...": "Analizando archivos y construyendo un árbol de archivos...",
+"Parsing files and building hybrid metadata...": "Analizando archivos y creando metadatos híbridos...",
+"Patch file format (auto: infer from extension or try JSON then TOML)": "Formato de archivo de parche (automático: inferir de la extensión o probar JSON y luego TOML)",
+"Patch must be a JSON/TOML object at the top level": "El parche debe ser un objeto JSON/TOML en el nivel superior",
+"Path": "Camino",
+"Path does not exist": "El camino no existe",
+"Path is not a file: %s": "La ruta no es un archivo: %s",
+"Path or magnet://...": "Camino o imán://...",
+"Path to config file": "Ruta al archivo de configuración",
+"Pause failed: {error}": "Pausa fallida: {error}",
+"Pause torrent": "Pausar torrente",
+"Paused": "En pausa",
+"Paused {info_hash}…": "En pausa {info_hash}…",
+"Peer": "Par",
+"Peer Details": "Detalles de pares",
+"Peer Distribution": "Distribución entre pares",
+"Peer Efficiency": "Eficiencia entre pares",
+"Peer Quality": "Calidad de pares",
+"Peer Quality Distribution": "Distribución de calidad entre pares",
+"Peer Selection": "Selección de pares",
+"Peer banning not yet implemented. Selected peer: {ip}:{port}": "La prohibición entre pares aún no se ha implementado. Par seleccionado: {ip}:{puerto}",
+"Peer distribution - Error: {error}": "Distribución entre pares: Error: {error}",
+"Peer not found": "Compañero no encontrado",
+"Peer quality - Error: {error}": "Calidad de pares - Error: {error}",
+"Peer quality data is unavailable in the current mode.": "Los datos de calidad de pares no están disponibles en el modo actual.",
+"Peer timeout (s)": "Tiempos de espera de pares",
+"Peer {ip}:{port} banned": "Par {ip}:{puerto} prohibido",
+"Peers Found": "Compañeros encontrados",
+"Peers/Q": "Compañeros/Q",
+"Per-Peer": "Por par",
+"Per-Peer tab - Data provider or executor not available": "Pestaña Por par: proveedor de datos o ejecutor no disponible",
+"Per-Torrent": "Por torrente",
+"Per-Torrent Config: {hash}...": "Configuración por torrent: {hash}....",
+"Per-Torrent Configuration": "Configuración por torrent",
+"Per-Torrent Configuration: {name}": "Configuración por torrent: {nombre}",
+"Per-Torrent Quality Summary": "Resumen de calidad por torrent",
+"Per-Torrent tab - Data provider or executor not available": "Pestaña Por Torrente: proveedor de datos o ejecutor no disponible",
+"Per-torrent DHT": "DHT por torrent",
+"Per-torrent configuration - Data provider/Executor or torrent not available": "Configuración por torrent: proveedor de datos/ejecutor o torrent no disponible",
+"Per-torrent configuration saved successfully": "La configuración por torrent se guardó correctamente",
+"Percentage": "Porcentaje",
+"Performance metrics": "Métricas de rendimiento",
+"Performance metrics - Error: {error}": "Métricas de rendimiento: error: {error}",
+"Permission denied": "Permiso denegado",
+"Piece Selection Strategy": "Estrategia de selección de piezas",
+"Piece selection metrics are not available yet for this torrent.": "Las métricas de selección de piezas aún no están disponibles para este torrent.",
+"Piece selection metrics are unavailable in the current mode.": "Las métricas de selección de piezas no están disponibles en el modo actual.",
+"Pieces Received": "Piezas recibidas",
+"Pieces Served": "Piezas servidas",
+"Pin Content in IPFS:": "Fijar contenido en IPFS:",
+"Pipeline Rejections": "Rechazos de canalización",
+"Pipeline Utilization": "Utilización de tuberías",
+"Please enter a torrent path or magnet link": "Ingrese una ruta de torrent o un enlace magnético",
+"Please fix parse errors before saving": "Corrija los errores de análisis antes de guardar.",
+"Please fix validation errors before saving": "Corrija los errores de validación antes de guardar.",
+"Please select a torrent first": "Por favor seleccione un torrent primero",
+"Poor": "Pobre",
+"Port for web interface": "Puerto para interfaz web",
+"Port: {port}, STUN: {stun_count} server(s)": "Puerto: {puerto}, STUN: {stun_count} servidor(es)",
+"Prefer Protocol v2 when available": "Prefiere el protocolo v2 cuando esté disponible",
+"Prefer over TCP": "Prefiero sobre TCP",
+"Prefer uTP when both TCP and uTP are available": "Prefiera uTP cuando tanto TCP como uTP estén disponibles",
+"Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s": "Prefiere v2: {prefer_v2} | Híbrido: {híbrido} | Tiempo de espera: {tiempo de espera}s",
+"Press Ctrl+C to stop the daemon": "Presione Ctrl+C para detener el demonio.",
+"Press Enter to configure this section": "Pulsa Enter para configurar esta sección",
+"Previous": "Anterior",
+"Previous Step": "Paso anterior",
+"Prioritize first piece": "Priorizar la primera pieza",
+"Prioritize last piece": "Priorizar la última pieza",
+"Prioritized Pieces": "Piezas priorizadas",
+"Priority (0 = normal, 1 = high, -1 = low):": "Prioridad (0 = normal, 1 = alta, -1 = baja):",
+"Priority level": "Nivel de prioridad",
+"Profile '{name}' not found": "Perfil '{nombre}' no encontrado",
+"Profile applied to {path}": "Perfil aplicado a {ruta}",
+"Profile config written to {path}": "Configuración de perfil escrita en {ruta}",
+"Profile: {name}": "Perfil: {nombre}",
+"Protocol v2 (BEP 52)": "Protocolo v2 (BEP 52)",
+"Protocols (Ctrl+)": "Protocolos (Ctrl+)",
+"Provide a VALUE argument or use --value=... for values with spaces or JSON": "Proporcione un argumento VALOR o use --value=... para valores con espacios o JSON",
+"Proxy config": "Configuración de proxy",
+"Public key must be 32 bytes (64 hex characters)": "La clave pública debe tener 32 bytes (64 caracteres hexadecimales)",
+"PyYAML is required for YAML export": "Se requiere PyYAML para exportar YAML",
+"PyYAML is required for YAML import": "Se requiere PyYAML para importar YAML",
+"PyYAML is required for YAML patches": "Se requiere PyYAML para los parches YAML",
+"Quality": "Calidad",
+"Quality Distribution": "Distribución de Calidad",
+"Queries": "Consultas",
+"Queries Received": "Consultas recibidas",
+"Queries Sent": "Consultas enviadas",
+"Quick Add Torrent": "Torrente de adición rápida",
+"Quick Stats": "Estadísticas rápidas",
+"Quick add torrent": "agregar torrent rapido",
+"RTT multiplier for retransmit timeout": "Multiplicador RTT para tiempo de espera de retransmisión",
+"Rainbow": "Arcoíris",
+"Rate Limits (KiB/s)": "Límites de velocidad (KiB/s)",
+"Rate limit configuration (global and per-torrent)": "Configuración del límite de velocidad (global y por torrent)",
+"Rates": "Tarifas",
+"Read IPC port %d from daemon config file (authoritative source)": "Lea el puerto IPC %d del archivo de configuración del demonio (fuente autorizada)",
+"Recent Security Events ({count})": "Eventos de seguridad recientes ({count})",
+"Recommended Settings": "Configuraciones recomendadas",
+"Recommended Value": "Valor recomendado",
+"Reconnect to peers from checkpoint": "Reconectarse con sus pares desde el punto de control",
+"Recovery & Pipeline Health": "Recuperación y salud del oleoducto",
+"Refresh": "Refrescar",
+"Refresh PEX": "Actualizar PEX",
+"Refresh tracker state from checkpoint": "Actualizar el estado del rastreador desde el punto de control",
+"Rehash: Failed": "Refrito: fallido",
+"Remaining chunks: {count}": "Fragmentos restantes: {count}",
+"Remove": "Eliminar",
+"Remove Tracker": "Eliminar rastreador",
+"Remove checkpoints older than N days": "Eliminar puntos de control con más de N días",
+"Remove failed: {error}": "Error al eliminar: {error}",
+"Remove tracker not yet implemented. Selected tracker: {url}": "Eliminar el rastreador aún no implementado. Rastreador seleccionado: {url}",
+"Reputation Tracking": "Seguimiento de reputación",
+"Request Efficiency": "Solicitar eficiencia",
+"Request Latency": "Solicitar latencia",
+"Request Success": "Solicitud exitosa",
+"Request pipeline depth": "Solicitar profundidad de tubería",
+"Required": "Requerido",
+"Reset specific key only (otherwise resets all options)": "Restablecer solo una clave específica (de lo contrario, restablece todas las opciones)",
+"Resource": "Recurso",
+"Resource Utilization": "Utilización de recursos",
+"Responses Received": "Respuestas recibidas",
+"Restart Required": "Se requiere reinicio",
+"Restart daemon now?": "¿Reiniciar el demonio ahora?",
+"Restore complete": "Restauración completa",
+"Restore failed": "Restauración fallida",
+"Restoring checkpoint...": "Restaurando el puesto de control...",
+"Resume failed: {error}": "Error al reanudar: {error}",
+"Resume from checkpoint if available": "Reanudar desde el punto de control si está disponible",
+"Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.": "Reanudar desde el punto de control si está disponible:\n\nSi está habilitado, la descarga se reanudará desde el último punto de control.",
+"Resume from checkpoint:": "Reanudar desde el punto de control:",
+"Resume from checkpoint?": "¿Reanudar desde el punto de control?",
+"Resume torrent": "Reanudar torrent",
+"Resumed {info_hash}…": "Reanudado {info_hash}…",
+"Resuming {name}": "Reanudando {nombre}",
+"Retransmit Timeout Factor": "Factor de tiempo de espera de retransmisión",
+"Routing Table": "Tabla de enrutamiento",
+"Routing table statistics not available.": "Estadísticas de la tabla de enrutamiento no disponibles.",
+"Rule not found: {ip_range}": "Regla no encontrada: {ip_range}",
+"Run additional system compatibility checks after model validation": "Ejecute comprobaciones adicionales de compatibilidad del sistema después de la validación del modelo",
+"Run in foreground (for debugging)": "Ejecutar en primer plano (para depurar)",
+"SSL config": "configuración SSL",
+"Save Config": "Guardar configuración",
+"Save Configuration": "Guardar configuración",
+"Save checkpoint after reset": "Guardar punto de control después del reinicio",
+"Save checkpoint immediately after setting option": "Guarde el punto de control inmediatamente después de configurar la opción",
+"Saving torrent to {path}...": "Guardando torrent en {ruta}....",
+"Scanning folder and calculating chunks...": "Escaneando carpeta y calculando fragmentos...",
+"Schema written to {path}": "Esquema escrito en {ruta}",
+"Scrape": "Roce",
+"Scrape Count": "Recuento de raspados",
+"Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.": "Opciones de raspado:\n\nEstadísticas de seguimiento de consultas de scraping (seeders, leechers, descargas completadas).\nEl raspado automático raspará automáticamente el rastreador cuando se agregue el torrent.",
+"Scrape results": "Resultados de raspado",
+"Scrape: Failed": "Raspado: fallido",
+"Search torrents...": "Buscar torrentes...",
+"Section": "Sección",
+"Section '{section}' is not a configuration section": "La sección '{section}' no es una sección de configuración",
+"Section '{section}' not found": "Sección '{sección}' no encontrada",
+"Section: {section}": "Sección: {sección}",
+"Security": "Seguridad",
+"Security Events": "Eventos de seguridad",
+"Security Scan Status": "Estado del análisis de seguridad",
+"Security Statistics": "Estadísticas de seguridad",
+"Security configuration - Data provider/Executor not available": "Configuración de seguridad: proveedor de datos/ejecutor no disponible",
+"Security manager not available. Security scanning requires local session mode.": "Gerente de seguridad no disponible. El escaneo de seguridad requiere el modo de sesión local.",
+"Security scan": "Escaneo de seguridad",
+"Security scan completed. No issues detected.": "Análisis de seguridad completado. No se detectaron problemas.",
+"Security scan completed. {blocked} blocked connections, {events} security events detected.": "Análisis de seguridad completado. {blocked} conexiones bloqueadas, {events} eventos de seguridad detectados.",
+"Security scan is not available when connected to daemon.": "El análisis de seguridad no está disponible cuando se conecta al demonio.",
+"Security settings (encryption, IP filtering, SSL)": "Configuración de seguridad (cifrado, filtrado de IP, SSL)",
+"Seeding": "siembra",
+"Seeds": "Semillas",
+"Select": "Seleccionar",
+"Select All": "Seleccionar todo",
+"Select File Priority": "Seleccionar prioridad de archivo",
+"Select Files to Download": "Seleccione archivos para descargar",
+"Select Language": "Seleccionar idioma",
+"Select Priority": "Seleccionar prioridad",
+"Select Section": "Seleccionar sección",
+"Select Theme": "Seleccionar tema",
+"Select a graph type to view": "Seleccione un tipo de gráfico para ver",
+"Select a section to configure": "Seleccione una sección para configurar",
+"Select a section to configure. Press Enter to edit, Escape to go back.": "Seleccione una sección para configurar. Presione Enter para editar, Escape para regresar.",
+"Select a sub-tab to view configuration options": "Seleccione una subpestaña para ver las opciones de configuración",
+"Select a sub-tab to view torrents": "Seleccione una subpestaña para ver torrents",
+"Select a torrent and sub-tab to view details": "Seleccione un torrent y una subpestaña para ver los detalles",
+"Select a torrent insight tab": "Seleccione una pestaña de información de torrent",
+"Select a workflow tab": "Seleccione una pestaña de flujo de trabajo",
+"Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all": "Seleccione archivos para descargar y establezca prioridades:\n Espacio: alternar selección\n P: Cambiar prioridad\n R: Seleccionar todo\n D: Deseleccionar todo",
+"Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)": "Seleccionar archivos: [todos], [n]uno o índices (por ejemplo, 0,2-5)",
+"Select folder": "Seleccionar carpeta",
+"Select playable file": "Seleccionar archivo reproducible",
+"Select queue priority for this torrent:\n\nHigher priority torrents will be started first.": "Seleccione la prioridad de cola para este torrent:\n\nLos torrents de mayor prioridad se iniciarán primero.",
+"Select torrent...": "Seleccione torrente...",
+"Selected {count} file(s)": "{count} archivos seleccionados",
+"Set Limits": "Establecer límites",
+"Set Priority": "Establecer prioridad",
+"Set locale (e.g., 'en', 'es', 'fr')": "Establecer configuración regional (por ejemplo, 'en', 'es', 'fr')",
+"Set priority to {priority} for file": "Establecer prioridad en {prioridad} para el archivo",
+"Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.": "Establece límites de velocidad para este torrent:\n\nIngrese 0 o déjelo vacío para ilimitado.",
+"Setting": "Configuración",
+"Share Ratio": "Proporción de participación",
+"Share failed": "Compartir falló",
+"Shared Peers": "Compañeros compartidos",
+"Show checkpoints in specific format": "Mostrar puntos de control en un formato específico",
+"Show what would be deleted without actually deleting": "Mostrar lo que se eliminaría sin eliminarlo realmente",
+"Shutdown timeout in seconds": "Tiempo de espera de apagado en segundos",
+"Size: {size}": "Tamaño: {tamaño}",
+"Skip & Continue": "Saltar y continuar",
+"Skip waiting and select all files": "Salta la espera y selecciona todos los archivos.",
+"Socket Optimizations": "Optimizaciones de sockets",
+"Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.": "Prueba de conexión de socket a %s:%d falló (resultado=%d). Es posible que el puerto no esté abierto o que el firewall esté bloqueado. Continuando con la verificación HTTP de todos modos.",
+"Socket manager not initialized": "Administrador de sockets no inicializado",
+"Socket receive buffer (KiB)": "Búfer de recepción de socket (KiB)",
+"Socket send buffer (KiB)": "Búfer de envío de socket (KiB)",
+"Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.": "La prueba de socket devolvió 10035 (WSAEWOULDBLOCK) en Windows para %s:%d. Esto puede ser un falso positivo; continúe con la verificación HTTP.",
+"Solarized Dark": "Solarizado Oscuro",
+"Solarized Light": "Luz solarizada",
+"Source path does not exist: %s": "La ruta de origen no existe: %s",
+"Speed Category": "Categoría de velocidad",
+"Speeds": "Velocidades",
+"Start Stream": "Iniciar transmisión",
+"Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.": "Inicie una transmisión para exponer una URL HTTP de host local para VLC u otro reproductor externo. La incrustación de vídeo nativo en la terminal está fuera de alcance.",
+"Start daemon in background without waiting for completion (faster startup)": "Inicie el demonio en segundo plano sin esperar a que finalice (inicio más rápido)",
+"Start interactive mode": "Iniciar modo interactivo",
+"Start the stream before opening VLC.": "Inicie la transmisión antes de abrir VLC.",
+"Starting daemon...": "Iniciando demonio...",
+"Starting file verification...": "Iniciando verificación de archivos...",
+"State: stopped\nSelected file index: {index}": "Estado: detenido\nÍndice del archivo seleccionado: {index}",
+"State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}": "Estado: {estado}\nURL: {url}\nPreparación del búfer: {búfer:.0%}",
+"Step {current}/{total}: {steps}": "Paso {actual}/{total}: {pasos}",
+"Stop Stream": "Detener transmisión",
+"Stopped": "Interrumpido",
+"Stopping daemon for restart...": "Deteniendo el demonio para reiniciar...",
+"Stopping daemon...": "Deteniendo al demonio...",
+"Stopping daemon... ({elapsed:.1f}s)": "Deteniendo demonio... ({elapsed:.1f}s)",
+"Storage": "Almacenamiento",
+"Storage Device Detection": "Detección de dispositivos de almacenamiento",
+"Storage Type": "Tipo de almacenamiento",
+"Storage configuration - Data provider/Executor not available": "Configuración de almacenamiento: proveedor/ejecutor de datos no disponible",
+"Strategy": "Estrategia",
+"Stuck Pieces Recovered": "Piezas atascadas recuperadas",
+"Submit": "Entregar",
+"Success": "Éxito",
+"Successful Requests": "Solicitudes exitosas",
+"Summary": "Resumen",
+"Supported MVP playback targets include common audio/video files.": "Los objetivos de reproducción MVP admitidos incluyen archivos de audio/vídeo comunes.",
+"Swarm Health": "Salud del enjambre",
+"Swarm Timeline": "Cronología del enjambre",
+"Swarm health - Error: {error}": "Salud del enjambre - Error: {error}",
+"Swarm timeline - Error: {error}": "Cronología del enjambre - Error: {error}",
+"System Efficiency": "Eficiencia del sistema",
+"System recommendations:": "Recomendaciones del sistema:",
+"System resources": "Recursos del sistema",
+"System resources - Error: {error}": "Recursos del sistema - Error: {error}",
+"Template '{name}' not found": "Plantilla '{nombre}' no encontrada",
+"Template applied to {path}": "Plantilla aplicada a {ruta}",
+"Template config written to {path}": "Configuración de plantilla escrita en {ruta}",
+"Template: {name}": "Plantilla: {nombre}",
+"Templates: {templates}": "Plantillas: {plantillas}",
+"Textual Dark": "Texto oscuro",
+"Theme": "Tema",
+"Theme: {theme}": "Tema: {tema}",
+"This torrent has no files to select.": "Este torrent no tiene archivos para seleccionar.",
+"This will modify your configuration file. Continue?": "Esto modificará su archivo de configuración. ¿Continuar?",
+"Tier": "Nivel",
+"Time": "Tiempo",
+"Timeline": "Línea de tiempo",
+"Timeline data is unavailable in the current mode.": "Los datos de la línea de tiempo no están disponibles en el modo actual.",
+"Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Tiempo de espera para comprobar la accesibilidad del demonio (intento %d/%d, transcurrido %.1fs), reintento en %.1fs...",
+"Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)": "Tiempo de espera para comprobar la accesibilidad del demonio después de %d intentos (transcurrido %.1fs)",
+"Timeout checking daemon status at %s (daemon may be starting up or overloaded)": "Se agotó el tiempo de espera para verificar el estado del demonio en %s (el demonio puede estar iniciando o sobrecargado)",
+"Tip: full option catalog and file merge → ": "Consejo: catálogo de opciones completas y combinación de archivos →",
+"Toggle Dark/Light": "Alternar oscuro/claro",
+"Tokyo Night": "Noche de Tokio",
+"Top 10 Peers by Quality": "Los 10 mejores pares por calidad",
+"Top profile entries:": "Entradas de perfil principales:",
+"Torrent": "Torrente",
+"Torrent Control": "Control de torrentes",
+"Torrent Controls": "Controles de torrents",
+"Torrent Controls - Data provider or executor not available": "Torrent Controls: proveedor o ejecutor de datos no disponible",
+"Torrent Controls - Error: {error}": "Controles de torrents - Error: {error}",
+"Torrent File Explorer": "Explorador de archivos torrent",
+"Torrent Information": "Información del torrente",
+"Torrent config": "Configuración de torrent",
+"Torrent file is empty: %s": "El archivo torrent está vacío: %s",
+"Torrent file not found: %s": "Archivo torrent no encontrado: %s",
+"Torrent paused": "Torrente en pausa",
+"Torrent priority": "Prioridad de torrent",
+"Torrent removed": "Torrente eliminado",
+"Torrent resumed": "Torrente reanudado",
+"Torrent saved to {path}": "Torrent guardado en {ruta}",
+"Torrents tab - Data provider or executor not available": "Pestaña Torrents: proveedor o ejecutor de datos no disponible",
+"Torrents with DHT": "Torrentes con DHT",
+"Total Buckets": "Total de depósitos",
+"Total Connections": "Conexiones totales",
+"Total Downloaded": "Total descargado",
+"Total Nodes": "Nodos totales",
+"Total Peers": "Total de pares",
+"Total Peers: {total} | Active Peers: {active}": "Total de pares: {total} | Compañeros activos: {activo}",
+"Total Queries": "Consultas totales",
+"Total Requests": "Solicitudes totales",
+"Total Size": "Tamaño total",
+"Total Uploaded": "Total Subido",
+"Total chunks: {count}": "Total de fragmentos: {count}",
+"Total queries": "Consultas totales",
+"Tracker": "Rastreador",
+"Tracker Error": "Error de seguimiento",
+"Tracker added: {url}": "Rastreador agregado: {url}",
+"Tracker announce interval (s)": "Intervalo(s) de anuncio del rastreador",
+"Tracker removed: {url}": "Rastreador eliminado: {url}",
+"Tracker scrape interval (s)": "Intervalo(s) de raspado del rastreador",
+"Trackers": "Rastreadores",
+"Tracking {count} torrent(s) across {minutes} minute window": "Seguimiento de {count} torrent(s) en una ventana de {minutos} minutos",
+"Trend: {trend} ({delta:+.1f}pp)": "Tendencia: {tendencia} ({delta:+.1f}pp)",
+"UI refresh interval: {interval}s": "Intervalo de actualización de la interfaz de usuario: {interval}s",
+"URL": "[ES] URL",
+"Unavailable": "Indisponible",
+"Unchoke interval (s)": "Intervalo(s) de liberación",
+"Unexpected error checking daemon status at %s: %s": "Error inesperado al comprobar el estado del demonio en %s: %s",
+"Unknown error": "Error desconocido",
+"Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.": "Se solicitó la operación desconocida '{operación}' pero existe el archivo PID del demonio. Esto no debería suceder; infórmalo como un error.",
+"Unknown operation: %s": "Operación desconocida: %s",
+"Unlimited": "Ilimitado",
+"Up (B/s)": "Arriba (B/s)",
+"Updated at {time}": "Actualizado a las {hora}",
+"Updated config file with daemon configuration": "Archivo de configuración actualizado con configuración de demonio",
+"Upload Limit": "Límite de carga",
+"Upload Limit (KiB/s):": "Límite de carga (KiB/s):",
+"Upload Rate": "Tasa de carga",
+"Upload Rate Limit (bytes/sec, 0 = unlimited):": "Límite de velocidad de carga (bytes/seg, 0 = ilimitado):",
+"Upload limit (KiB/s, 0 = unlimited)": "Límite de carga (KiB/s, 0 = ilimitado)",
+"Upload:": "Subir:",
+"Uploaded": "subido",
+"Uploading": "Subiendo",
+"Uptime": "tiempo de actividad",
+"Usage": "Uso",
+"Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema": "Uso: configuración [mostrar|obtener|establecer|recargar] ...\nShell: descripción de configuración btbt | aplicar | importar | esquema",
+"Usage: disk [show|stats|config |monitor]": "Uso: disco [mostrar|estadísticas|config |monitor]",
+"Usage: network [show|stats|config |optimize|monitor]": "Uso: red [mostrar|estadísticas|config |optimizar|monitor]",
+"Use 'btbt daemon restart' or restart the daemon manually.": "Utilice 'reinicio del demonio btbt' o reinicie el demonio manualmente.",
+"Use --confirm to proceed with restore": "Utilice --confirm para continuar con la restauración",
+"Use --force to force kill": "Utilice --force para forzar la muerte",
+"Use Protocol v2 only (disable v1)": "Utilice solo el protocolo v2 (deshabilite v1)",
+"Use memory mapping": "Usar mapeo de memoria",
+"Using IPC port %d from main config": "Usando el puerto IPC %d desde la configuración principal",
+"Using daemon config file: port=%d, api_key_present=%s": "Usando el archivo de configuración del demonio: puerto=%d, api_key_present=%s",
+"Using daemon executor for magnet command": "Usando daemon executor para el comando magnético",
+"Using default IPC port %d (daemon config file may not exist)": "Usando el puerto IPC predeterminado %d (es posible que el archivo de configuración del demonio no exista)",
+"Utilization Median": "Mediana de utilización",
+"Utilization Range": "Rango de utilización",
+"Utilization Samples": "Muestras de utilización",
+"V1 torrent generation not yet implemented": "La generación de torrent V1 aún no está implementada",
+"VS Code Dark": "Código VS oscuro",
+"Validate merged file overlay only; do not write": "Validar únicamente la superposición de archivos combinados; no escribas",
+"Validate only; do not write the config file": "Validar sólo; no escribas el archivo de configuración",
+"Validation error: %s": "Error de validación: %s",
+"Value to set (use for strings with spaces or JSON); overrides positional VALUE": "Valor a establecer (úselo para cadenas con espacios o JSON); anula el VALOR posicional",
+"Verification complete: {verified} verified, {failed} failed out of {total}": "Verificación completa: {verificado} verificado, {fallido} falló en {total}",
+"Verification failed: {error}": "Error de verificación: {error}",
+"Verify Files": "Verificar archivos",
+"Visual": "[ES] Visual",
+"Wait for Metadata": "Esperar metadatos",
+"Wait for metadata and prompt for file selection (interactive only)": "Espere los metadatos y solicite la selección del archivo (solo interactivo)",
+"Warnings:": "Advertencias:",
+"WebSocket error in batch receive: %s": "Error de WebSocket en la recepción por lotes: %s",
+"WebSocket error: %s": "Error de WebSocket: %s",
+"WebSocket receive loop error: %s": "Error de bucle de recepción de WebSocket: %s",
+"WebTorrent": "[ES] WebTorrent",
+"Whitelist Size": "Tamaño de la lista blanca",
+"Whitelisted Peers": "Compañeros incluidos en la lista blanca",
+"Windows-specific error checking daemon (os.kill() issue): %s - no PID file found, will create local session": "Demonio de comprobación de errores específico de Windows (problema os.kill()): %s: no se encontró ningún archivo PID, se creará una sesión local",
+"Write Batch Timeout": "Tiempo de espera de escritura por lotes",
+"Write batch size (KiB)": "Escribir tamaño de lote (KiB)",
+"Write buffer size (KiB)": "Tamaño del búfer de escritura (KiB)",
+"Write merged config to global config file": "Escriba la configuración combinada en el archivo de configuración global",
+"Write merged config to project local ccbt.toml": "Escriba la configuración fusionada en el proyecto ccbt.toml local",
+"Write-Back Cache": "Caché de reescritura",
+"Writing export file...": "Escribiendo archivo de exportación...",
+"Wrote catalog to {path}": "Escribí el catálogo en {ruta}",
+"XET Folders": "Carpetas XET",
+"Xet Protocol Options:\n\nXet enables content-defined chunking and deduplication.\nUseful for reducing storage when downloading similar content.": "Opciones del protocolo Xet:\n\nXet permite la fragmentación y deduplicación definidas por contenido.\nÚtil para reducir el almacenamiento al descargar contenido similar.",
+"Xet management": "Gestión de xet",
+"You can skip waiting and continue with all files selected.": "Puedes saltarte la espera y continuar con todos los archivos seleccionados.",
+"Zero-state count": "Recuento de estado cero",
+"[blue]Progress: {verified}/{total} pieces verified[/blue]": "[azul]Progreso: {verificado}/{total} piezas verificadas[/azul]",
+"[blue]Running: {command}[/blue]": "[azul]Ejecutando: {comando}[/azul]",
+"[bold green]Share link:[/bold green]": "[negrita verde]Compartir enlace:[/negrita verde]",
+"[bold]Aliases ({count}):[/bold]\n": "[bold]Alias ({count}):[/bold]",
+"[bold]Allowlist ({count} peers):[/bold]\n": "[bold]Lista de permitidos ({count} pares):[/bold]",
+"[bold]Configuration:[/bold]": "[negrita]Configuración:[/negrita]",
+"[bold]Discovering NAT devices...[/bold]\n": "[bold]Descubriendo dispositivos NAT...[/bold]",
+"[bold]Mapping {protocol} port {port}...[/bold]": "[bold]Asignación de {protocolo} puerto {puerto}....[/bold]",
+"[bold]NAT Traversal Status[/bold]\n": "[bold]Estado transversal de NAT[/bold]",
+"[bold]Removing {protocol} port mapping for port {port}...[/bold]": "[bold]Eliminando la asignación de puerto {protocolo} para el puerto {puerto}...[/bold]",
+"[bold]Sync Mode for: {path}[/bold]\n": "[bold]Modo de sincronización para: {ruta}[/bold]",
+"[bold]Sync Status for: {path}[/bold]\n": "[bold]Estado de sincronización para: {ruta}[/bold]",
+"[bold]Xet Cache Information[/bold]\n": "[bold]Información de caché de Xet[/bold]",
+"[bold]Xet Deduplication Cache Statistics[/bold]\n": "[bold]Estadísticas de caché de deduplicación de Xet[/bold]",
+"[bold]Xet Protocol Status[/bold]\n": "[bold]Estado del protocolo Xet[/bold]",
+"[cyan]Checking for existing daemon instance...[/cyan]": "[cian]Comprobando la instancia de demonio existente...[/cian]",
+"[cyan]Creating {format} torrent...[/cyan]": "[cian]Creando {formato} torrent...[/cian]",
+"[cyan]Download:[/cyan] {rate:.2f} KiB/s": "[cian]Descargar:[/cian] {tasa:.2f} KiB/s",
+"[cyan]Initializing configuration...[/cyan]": "[cian]Iniciando configuración...[/cian]",
+"[cyan]Loading filter from: {file_path}[/cyan]": "[cyan]Cargando filtro desde: {file_path}[/cyan]",
+"[cyan]Restarting daemon...[/cyan]": "[cian]Reiniciando demonio...[/cian]",
+"[cyan]Running diagnostic checks...[/cyan]\n": "[cian]Ejecutando comprobaciones de diagnóstico...[/cian]",
+"[cyan]Starting daemon in background...[/cyan]": "[cian]Iniciando demonio en segundo plano...[/cian]",
+"[cyan]Starting daemon in foreground mode...[/cyan]": "[cian]Iniciando demonio en modo de primer plano...[/cian]",
+"[cyan]Testing proxy connection to {host}:{port}...[/cyan]": "[cian]Probando la conexión proxy a {host}:{puerto}....[/cian]",
+"[cyan]Torrents:[/cyan] {num_torrents}": "[cian]Torrents:[/cian] {num_torrents}",
+"[cyan]Updating filter lists from {count} URL(s)...[/cyan]": "[cian]Actualizando listas de filtros de {count} URL(s)...[/cyan]",
+"[cyan]Upload:[/cyan] {rate:.2f} KiB/s": "[cian]Subir:[/cian] {velocidad:.2f} KiB/s",
+"[cyan]Uptime:[/cyan] {uptime:.1f}s": "[cian]Tiempo de actividad:[/cian] {tiempo de actividad:.1f}s",
+"[cyan]Using custom IPC port: {port}[/cyan]": "[cian]Usando el puerto IPC personalizado: {puerto}[/cian]",
+"[cyan]Waiting for daemon to be ready...[/cyan]": "[cian]Esperando que el demonio esté listo...[/cian]",
+"[dim] uv run btbt daemon start --foreground[/dim]": "[dim] uv ejecutar btbt daemon start --primer plano[/dim]",
+"[dim]Daemon may still be starting. Use 'btbt daemon status' to check.[/dim]": "[dim] Es posible que Daemon todavía esté iniciando. Utilice 'btbt daemon status' para comprobarlo.[/dim]",
+"[dim]Info hash v1 (SHA-1): {hash}...[/dim]": "[dim]Hash de información v1 (SHA-1): {hash}....[/dim]",
+"[dim]Info hash v2 (SHA-256): {hash}...[/dim]": "[dim]Hash de información v2 (SHA-256): {hash}....[/dim]",
+"[dim]No active port mappings[/dim]": "[dim]No hay asignaciones de puertos activas[/dim]",
+"[dim]Output: {path}[/dim]": "[dim]Salida: {ruta}[/dim]",
+"[dim]Please restart manually: 'btbt daemon restart'[/dim]": "[dim] Reinicie manualmente: 'btbt daemon restart' [/dim]",
+"[dim]Please restart the daemon manually: 'btbt daemon restart'[/dim]": "[dim] Reinicie el demonio manualmente: 'btbt daemon restart'[/dim]",
+"[dim]Protocol: {method}[/dim]": "[dim]Protocolo: {método}[/dim]",
+"[dim]See daemon log: {path}[/dim]": "[dim]Ver registro del demonio: {ruta}[/dim]",
+"[dim]Source: {path}[/dim]": "[dim]Fuente: {ruta}[/dim]",
+"[dim]Trackers: {count}[/dim]": "[dim]Rastreadores: {count}[/dim]",
+"[dim]Try running with --foreground flag to see detailed error output:[/dim]": "[dim] Intente ejecutar con el indicador --foreground para ver el resultado de error detallado: [/dim]",
+"[dim]Use 'btbt daemon status' to check daemon status[/dim]": "[dim] Utilice 'btbt daemon status' para verificar el estado del demonio [/dim]",
+"[dim]Use -v flag for more details or check daemon logs[/dim]": "[dim]Utilice el indicador -v para obtener más detalles o consulte los registros del demonio[/dim]",
+"[dim]Web seeds: {count}[/dim]": "[dim]Semillas web: {count}[/dim]",
+"[green]ALLOWED[/green]": "[verde]PERMITIDO[/verde]",
+"[green]Active Protocol:[/green] {method}": "[verde]Protocolo activo:[/verde] {método}",
+"[green]Added alert rule {name}[/green]": "[verde] Regla de alerta agregada {nombre}[/verde]",
+"[green]Added to IPFS:[/green] {cid}": "[verde]Agregado a IPFS:[/green] {cid}",
+"[green]Applying {preset} optimizations...[/green]": "[verde]Aplicando optimizaciones {preestablecidas}...[/verde]",
+"[green]Benchmark results:[/green] {results}": "[verde]Resultados de referencia:[/verde] {resultados}",
+"[green]CA certificates path set to {path}. Configuration saved to {config_file}[/green]": "[verde]Ruta de los certificados de CA establecida en {ruta}. Configuración guardada en {config_file}[/green]",
+"[green]Checkpoint for {hash} is valid[/green]": "[verde]El punto de control de {hash} es válido[/verde]",
+"[green]Checkpoint for {info_hash} is valid[/green]": "[verde]El punto de control de {info_hash} es válido[/verde]",
+"[green]Checkpoint refreshed for {hash}[/green]": "[verde]Punto de control actualizado para {hash}[/green]",
+"[green]Checkpoint reloaded for {hash}[/green]": "[verde]Punto de control recargado para {hash}[/green]",
+"[green]Checkpoint saved for torrent[/green]": "[verde]Punto de control guardado para torrent[/verde]",
+"[green]Checkpoint saved[/green]": "[verde]Punto de control guardado[/verde]",
+"[green]Checkpoint valid[/green]": "[verde]Punto de control válido[/verde]",
+"[green]Cleared all active alerts[/green]": "[verde]Se borraron todas las alertas activas[/verde]",
+"[green]Cleared queue[/green]": "[verde]Cola despejada[/verde]",
+"[green]Client certificate set. Configuration saved to {config_file}[/green]": "[verde] Conjunto de certificados de cliente. Configuración guardada en {config_file}[/green]",
+"[green]Connected to daemon[/green]": "[verde]Conectado al demonio[/verde]",
+"[green]Content pinned[/green]": "[verde]Contenido fijado[/verde]",
+"[green]Content saved to:[/green] {output}": "[verde]Contenido guardado en:[/green] {salida}",
+"[green]DHT aggressive mode {mode} for torrent: {info_hash}[/green]": "[verde]Modo agresivo DHT {mode} para torrent: {info_hash}[/green]",
+"[green]Daemon is running[/green] (PID: {pid})": "[verde]El demonio se está ejecutando[/verde] (PID: {pid})",
+"[green]Daemon restarted successfully[/green]": "[verde]El demonio se reinició correctamente[/verde]",
+"[green]Daemon stopped gracefully[/green]": "[verde]Daemon se detuvo con gracia[/verde]",
+"[green]Daemon stopped[/green]": "[verde]Demonio detenido[/verde]",
+"[green]Deleted checkpoint for {hash}[/green]": "[verde]Punto de control eliminado para {hash}[/green]",
+"[green]Deleted checkpoint for {info_hash}[/green]": "[green]Punto de control eliminado para {info_hash}[/green]",
+"[green]Deselected all files.[/green]": "[verde]Se deseleccionaron todos los archivos.[/verde]",
+"[green]Deselected all files[/green]": "[verde]Se deseleccionaron todos los archivos[/verde]",
+"[green]Deselected {count} file(s)[/green]": "[verde] {count} archivo(s) deseleccionado(s)[/green]",
+"[green]External IP:[/green] {ip}": "[verde]IP externa:[/verde] {ip}",
+"[green]Force started {count} torrent(s)[/green]": "[verde]Forzar el inicio de {count} torrent(s)[/green]",
+"[green]Found checkpoint for: {torrent_name}[/green]": "[verde]Punto de control encontrado para: {torrent_name}[/green]",
+"[green]Integrity verification passed: {count} pieces verified[/green]": "[verde]Verificación de integridad aprobada: {count} piezas verificadas[/green]",
+"[green]Loaded alert rules from {path}[/green]": "[verde]Reglas de alerta cargadas desde {ruta}[/verde]",
+"[green]Loaded {count} alert rules from {path}[/green]": "[verde] Se cargaron {count} reglas de alerta desde {ruta}[/green]",
+"[green]Locale set to: {locale_code}[/green]": "[verde] Configuración regional establecida en: {locale_code}[/green]",
+"[green]Magnet link added to daemon: {info_hash}[/green]": "[verde]Enlace magnético agregado al demonio: {info_hash}[/green]",
+"[green]Moved to position {position}[/green]": "[verde]Movido a la posición {posición}[/verde]",
+"[green]Network configuration looks optimal![/green]": "[verde]¡La configuración de red parece óptima![/verde]",
+"[green]No checkpoints older than {days} days found[/green]": "[verde]No se encontraron puntos de control con más de {días} días[/verde]",
+"[green]Optimizations applied successfully![/green]\n[yellow]Note: Some changes may require restart to take effect.[/yellow]": "[verde] ¡Optimizaciones aplicadas correctamente! [/verde]\n[amarillo]Nota: Es posible que algunos cambios requieran reiniciar para que surtan efecto.[/amarillo]",
+"[green]Optimizations saved to {path}[/green]": "[verde]Optimizaciones guardadas en {ruta}[/verde]",
+"[green]PEX refreshed for torrent: {info_hash}[/green]": "[verde]PEX actualizado para torrent: {info_hash}[/green]",
+"[green]Paused torrent[/green]": "[verde]Torrent en pausa[/verde]",
+"[green]Paused {count} torrent(s)[/green]": "[verde] {count} torrent(s) en pausa[/green]",
+"[green]Peer validation hooks are enabled by configuration[/green]": "[verde] Los enlaces de validación de pares están habilitados mediante configuración [/verde]",
+"[green]Per-peer rate limit for {peer_key}: {limit}[/green]": "[green]Límite de tasa por par para {peer_key}: {limit}[/green]",
+"[green]Per-peer rate limit set: {peer_key} = {upload} KiB/s[/green]": "[verde] Límite de tasa por par establecido: {peer_key} = {upload} KiB/s[/green]",
+"[green]Performing basic configuration scan...[/green]": "[verde]Realizando escaneo de configuración básica...[/verde]",
+"[green]Pinned:[/green] {cid}": "[verde]Fijado:[/green] {cid}",
+"[green]Proxy configuration saved to {config_file}[/green]": "[verde]Configuración de proxy guardada en {config_file}[/green]",
+"[green]Proxy configuration updated successfully[/green]": "[verde]Configuración de proxy actualizada correctamente[/verde]",
+"[green]Proxy has been disabled[/green]": "[verde]El proxy ha sido deshabilitado[/verde]",
+"[green]Removed alert rule {name}[/green]": "[verde]Regla de alerta eliminada {nombre}[/verde]",
+"[green]Removed torrent from queue[/green]": "[verde]Torrent eliminado de la cola[/verde]",
+"[green]Reset all options for torrent {hash}[/green]": "[verde]Restablecer todas las opciones de torrent {hash}[/green]",
+"[green]Reset {key} for torrent {hash}[/green]": "[verde]Restablecer {clave} para torrent {hash}[/verde]",
+"[green]Restored checkpoint for: {name}[/green]\nInfo hash: {hash}": "[verde]Punto de control restaurado para: {nombre}[/verde]\nHash de información: {hash}",
+"[green]Resume data structure is valid[/green]": "[verde]La estructura de datos del currículum es válida[/verde]",
+"[green]Resumed torrent[/green]": "[verde]Torrent reanudado[/verde]",
+"[green]Resumed {count} torrent(s)[/green]": "[verde] {count} torrent(s) reanudados[/green]",
+"[green]Resuming from checkpoint[/green]": "[verde]Reanudando desde el punto de control[/verde]",
+"[green]SSL certificate verification enabled. Configuration saved to {config_file}[/green]": "[verde]Verificación del certificado SSL habilitada. Configuración guardada en {config_file}[/green]",
+"[green]SSL for peers disabled. Configuration saved to {config_file}[/green]": "[verde]SSL para pares deshabilitado. Configuración guardada en {config_file}[/green]",
+"[green]SSL for peers enabled (experimental). Configuration saved to {config_file}[/green]": "[verde]SSL para pares habilitado (experimental). Configuración guardada en {config_file}[/green]",
+"[green]SSL for trackers disabled. Configuration saved to {config_file}[/green]": "[verde] SSL para rastreadores deshabilitado. Configuración guardada en {config_file}[/green]",
+"[green]SSL for trackers enabled. Configuration saved to {config_file}[/green]": "[verde] SSL para rastreadores habilitado. Configuración guardada en {config_file}[/green]",
+"[green]Saved alert rules to {path}[/green]": "[verde]Reglas de alerta guardadas en {ruta}[/verde]",
+"[green]Saved resume data for {hash}[/green]": "[verde]Datos de currículum guardados para {hash}[/green]",
+"[green]Selected all files[/green]": "[verde]Seleccionó todos los archivos[/verde]",
+"[green]Selected {count} file(s).[/green]": "[verde] {count} archivo(s) seleccionado(s).[/green]",
+"[green]Selected {count} file(s)[/green]": "[verde] {count} archivo(s) seleccionado(s)[/green]",
+"[green]Set file {index} priority to {priority}[/green]": "[verde] Establecer la prioridad del archivo {index} en {prioridad}[/green]",
+"[green]Set priority to {priority}[/green]": "[verde]Establecer prioridad en {prioridad}[/verde]",
+"[green]Set rate limit for {count} peers: {upload} KiB/s[/green]": "[verde]Establecer límite de velocidad para {count} pares: {upload} KiB/s[/green]",
+"[green]Set {key} = {value} for torrent {hash}[/green]": "[verde]Establezca {clave} = {valor} para torrent {hash}[/verde]",
+"[green]Successfully resumed download: {hash}[/green]": "[verde]Descarga reanudada exitosamente: {hash}[/green]",
+"[green]Successfully resumed download: {resumed_info_hash}[/green]": "[verde]Descarga reanudada exitosamente: {resumed_info_hash}[/green]",
+"[green]TLS protocol version set to {version}. Configuration saved to {config_file}[/green]": "[verde] Versión del protocolo TLS configurada en {versión}. Configuración guardada en {config_file}[/green]",
+"[green]Tested rule {name} with value {value}[/green]": "[verde]Regla probada {nombre} con valor {valor}[/verde]",
+"[green]Torrent added to daemon: {info_hash}[/green]": "[verde]Torrent agregado al demonio: {info_hash}[/green]",
+"[green]Torrent cancelled: {info_hash}{checkpoint_info}[/green]": "[verde]Torrent cancelado: {info_hash}{checkpoint_info}[/green]",
+"[green]Torrent force started: {info_hash}[/green]": "[verde] Se inició la fuerza del torrente: {info_hash}[/green]",
+"[green]Torrent paused: {info_hash}{checkpoint_info}[/green]": "[verde]Torrent en pausa: {info_hash}{checkpoint_info}[/green]",
+"[green]Torrent resumed: {info_hash}{checkpoint_info}[/green]": "[verde]Torrent reanudado: {info_hash}{checkpoint_info}[/green]",
+"[green]Tracker added: {url} to torrent {info_hash}[/green]": "[verde]Rastreador agregado: {url} a torrent {info_hash}[/green]",
+"[green]Tracker removed: {url} from torrent {info_hash}[/green]": "[verde]Rastreador eliminado: {url} de torrent {info_hash}[/green]",
+"[green]Unpinned:[/green] {cid}": "[verde]Desfijado:[/verde] {cid}",
+"[green]Updated {key} to {value}[/green]": "[verde]Se actualizó {clave} a {valor}[/verde]",
+"[green]Wrote metrics to {path}[/green]": "[verde]Escribió métricas en {ruta}[/verde]",
+"[green]{message}: {config_file}[/green]": "[verde]{mensaje}: {config_file}[/verde]",
+"[green]✓ Port mapping removed[/green]": "[verde] ✓ Se eliminó la asignación de puertos [/verde]",
+"[green]✓ Port mapping successful![/green]": "[verde] ✓ ¡Asignación de puertos exitosa! [/verde]",
+"[green]✓ Port mappings refreshed[/green]": "[verde] ✓ Asignaciones de puertos actualizadas [/verde]",
+"[green]✓ Proxy connection test successful[/green]": "[verde] ✓ Prueba de conexión de proxy exitosa[/verde]",
+"[green]✓ Torrent created successfully: {path}[/green]": "[verde] ✓ Torrente creado correctamente: {ruta}[/verde]",
+"[green]✓[/green] Added filter rule: {ip_range} ({mode})": "[verde] ✓[/verde] Regla de filtro agregada: {ip_range} ({mode})",
+"[green]✓[/green] Added peer {peer_id} to allowlist": "[verde] ✓[/verde] Se agregó el par {peer_id} a la lista de permitidos",
+"[green]✓[/green] Added peer {peer_id} to allowlist with alias '{alias}'": "[verde] ✓[/verde] Se agregó el par {peer_id} a la lista de permitidos con el alias '{alias}'",
+"[green]✓[/green] Cleaned {cleaned} unused chunks": "[verde] ✓[/verde] Limpiados {limpiados} trozos no utilizados",
+"[green]✓[/green] Configuration saved to {file}": "[verde] ✓[/verde] Configuración guardada en {archivo}",
+"[green]✓[/green] Daemon process started (PID {pid})": "[verde] ✓[/verde] Proceso de demonio iniciado (PID {pid})",
+"[green]✓[/green] Daemon started successfully (PID {pid}, took {elapsed:.1f}s)": "[verde] ✓[/verde] El demonio se inició correctamente (PID {pid}, tardó {elapsed:.1f}s)",
+"[green]✓[/green] Folder sync started": "[verde] ✓[/verde] Se inició la sincronización de carpetas",
+"[green]✓[/green] Generated .tonic file: {file}": "[verde] ✓[/verde] Archivo .tonic generado: {archivo}",
+"[green]✓[/green] Generated new API key for daemon": "[verde] ✓[/verde] Nueva clave API generada para el demonio",
+"[green]✓[/green] Generated tonic?: link:": "[verde] ✓[/verde] ¿Tónico generado?: enlace:",
+"[green]✓[/green] Loaded {loaded} rules from {file_path}": "[verde] ✓[/verde] Reglas {cargadas} cargadas desde {file_path}",
+"[green]✓[/green] Loaded {total_loaded} total rules": "[verde] ✓[/verde] Total de reglas cargadas {total_loaded}",
+"[green]✓[/green] Removed alias for peer {peer_id}": "[verde] ✓[/verde] Alias eliminado para el par {peer_id}",
+"[green]✓[/green] Removed filter rule: {ip_range}": "[verde] ✓[/verde] Regla de filtro eliminada: {ip_range}",
+"[green]✓[/green] Removed peer {peer_id} from allowlist": "[verde] ✓[/verde] Se eliminó el par {peer_id} de la lista de permitidos",
+"[green]✓[/green] Set alias '{alias}' for peer {peer_id}": "[verde] ✓[/verde] Establecer alias '{alias}' para el par {peer_id}",
+"[green]✓[/green] Set {key} = {value}": "[verde] ✓[/verde] Establecer {clave} = {valor}",
+"[green]✓[/green] Successfully updated {count} filter list(s)": "[verde] ✓[/verde] {count} lista(s) de filtros actualizadas correctamente",
+"[green]✓[/green] Sync mode updated": "[verde] ✓[/verde] Modo de sincronización actualizado",
+"[green]✓[/green] Tonic link:": "[verde] ✓[/verde] Enlace tónico:",
+"[green]✓[/green] Updated config file: {file}": "[verde] ✓[/verde] Archivo de configuración actualizado: {archivo}",
+"[green]✓[/green] Xet protocol enabled": "[verde] ✓[/verde] Protocolo Xet habilitado",
+"[green]✓[/green] uTP configuration reset to defaults": "[verde] ✓[/verde] configuración de uTP restablecida a los valores predeterminados",
+"[green]✓[/green] uTP transport enabled": "[verde] ✓[/verde] transporte uTP habilitado",
+"[red]--name is required to remove a rule[/red]": "[rojo]--el nombre es necesario para eliminar una regla[/rojo]",
+"[red]--name is required to test a rule[/red]": "[rojo]--el nombre es necesario para probar una regla[/rojo]",
+"[red]--name, --metric and --condition are required to add a rule[/red]": "[rojo]--name, --metric y --condition son necesarios para agregar una regla[/red]",
+"[red]--value is required with --test[/red]": "[rojo]--el valor es obligatorio con --test[/red]",
+"[red]BLOCKED[/red]": "[rojo]BLOQUEADO[/rojo]",
+"[red]Certificate file does not exist: {path}[/red]": "[rojo]El archivo de certificado no existe: {ruta}[/rojo]",
+"[red]Certificate path must be a file: {path}[/red]": "[red]La ruta del certificado debe ser un archivo: {path}[/red]",
+"[red]Configuration key not found: {key}[/red]": "[rojo]Clave de configuración no encontrada: {key}[/red]",
+"[red]Content not found: {cid}[/red]": "[rojo]Contenido no encontrado: {cid}[/red]",
+"[red]Daemon is not running[/red]": "[rojo]El demonio no se está ejecutando[/rojo]",
+"[red]Daemon process crashed[/red]": "[rojo]El proceso del demonio falló[/rojo]",
+"[red]Dashboard error: {e}[/red]": "[rojo]Error del panel: {e}[/red]",
+"[red]Directories not yet supported[/red]": "[red]Directorios aún no compatibles[/red]",
+"[red]Error adding content: {e}[/red]": "[red]Error al agregar contenido: {e}[/red]",
+"[red]Error adding peer to allowlist: {e}[/red]": "[red]Error al agregar un par a la lista de permitidos: {e}[/red]",
+"[red]Error disabling SSL for peers: {e}[/red]": "[red]Error al deshabilitar SSL para pares: {e}[/red]",
+"[red]Error disabling SSL for trackers: {e}[/red]": "[red]Error al desactivar SSL para rastreadores: {e}[/red]",
+"[red]Error disabling Xet protocol: {e}[/red]": "[red]Error al desactivar el protocolo Xet: {e}[/red]",
+"[red]Error disabling certificate verification: {e}[/red]": "[red]Error al deshabilitar la verificación del certificado: {e}[/red]",
+"[red]Error during cleanup: {e}[/red]": "[red]Error durante la limpieza: {e}[/red]",
+"[red]Error enabling SSL for peers: {e}[/red]": "[red]Error al habilitar SSL para pares: {e}[/red]",
+"[red]Error enabling SSL for trackers: {e}[/red]": "[red]Error al habilitar SSL para rastreadores: {e}[/red]",
+"[red]Error enabling Xet protocol: {e}[/red]": "[red]Error al habilitar el protocolo Xet: {e}[/red]",
+"[red]Error enabling certificate verification: {e}[/red]": "[red]Error al habilitar la verificación del certificado: {e}[/red]",
+"[red]Error ensuring daemon is running: {e}[/red]": "[rojo]Error al garantizar que el demonio se esté ejecutando: {e}[/red]",
+"[red]Error generating .tonic file: {e}[/red]": "[rojo]Error al generar el archivo .tonic: {e}[/red]",
+"[red]Error generating tonic link: {e}[/red]": "[red]Error al generar el enlace tónico: {e}[/red]",
+"[red]Error getting SSL status: {e}[/red]": "[red]Error al obtener el estado SSL: {e}[/red]",
+"[red]Error getting Xet status: {e}[/red]": "[red]Error al obtener el estado de Xet: {e}[/red]",
+"[red]Error getting content: {e}[/red]": "[red]Error al obtener contenido: {e}[/red]",
+"[red]Error getting peers: {e}[/red]": "[red]Error al obtener pares: {e}[/red]",
+"[red]Error getting stats: {e}[/red]": "[red]Error al obtener estadísticas: {e}[/red]",
+"[red]Error getting status: {e}[/red]": "[rojo]Error al obtener el estado: {e}[/red]",
+"[red]Error getting sync mode: {e}[/red]": "[red]Error al obtener el modo de sincronización: {e}[/red]",
+"[red]Error listing aliases: {e}[/red]": "[red]Error al enumerar alias: {e}[/red]",
+"[red]Error listing allowlist: {e}[/red]": "[red]Error en la lista de permitidos: {e}[/red]",
+"[red]Error pinning content: {e}[/red]": "[red]Error al fijar contenido: {e}[/red]",
+"[red]Error reading authenticated swarm status: {e}[/red]": "[rojo]Error al leer el estado del enjambre autenticado: {e}[/red]",
+"[red]Error removing alias: {e}[/red]": "[rojo]Error al eliminar el alias: {e}[/red]",
+"[red]Error removing peer from allowlist: {e}[/red]": "[red]Error al eliminar el par de la lista de permitidos: {e}[/red]",
+"[red]Error restarting daemon: {e}[/red]": "[rojo]Error al reiniciar el demonio: {e}[/red]",
+"[red]Error retrieving cache info: {e}[/red]": "[rojo]Error al recuperar información de caché: {e}[/red]",
+"[red]Error retrieving disk statistics: {error}[/red]": "[rojo]Error al recuperar estadísticas del disco: {error}[/red]",
+"[red]Error retrieving network statistics: {error}[/red]": "[red]Error al recuperar estadísticas de red: {error}[/red]",
+"[red]Error retrieving stats: {e}[/red]": "[red]Error al recuperar estadísticas: {e}[/red]",
+"[red]Error setting CA certificates path: {e}[/red]": "[rojo]Error al configurar la ruta de los certificados de CA: {e}[/red]",
+"[red]Error setting alias: {e}[/red]": "[rojo]Error al configurar el alias: {e}[/red]",
+"[red]Error setting client certificate: {e}[/red]": "[rojo]Error al configurar el certificado de cliente: {e}[/red]",
+"[red]Error setting protocol version: {e}[/red]": "[rojo]Error al configurar la versión del protocolo: {e}[/red]",
+"[red]Error setting sync mode: {e}[/red]": "[rojo]Error al configurar el modo de sincronización: {e}[/red]",
+"[red]Error starting sync: {e}[/red]": "[red]Error al iniciar la sincronización: {e}[/red]",
+"[red]Error unpinning content: {e}[/red]": "[red]Error al desanclar contenido: {e}[/red]",
+"[red]Error updating authenticated swarm mode: {e}[/red]": "[rojo]Error al actualizar el modo enjambre autenticado: {e}[/red]",
+"[red]Error updating configuration: {error}[/red]": "[rojo]Error al actualizar la configuración: {error}[/red]",
+"[red]Error updating discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento: {e}[/red]",
+"[red]Error updating parse-policy behavior: {e}[/red]": "[rojo]Error al actualizar el comportamiento de la política de análisis: {e}[/red]",
+"[red]Error updating strict discovery mode: {e}[/red]": "[red]Error al actualizar el modo de descubrimiento estricto: {e}[/red]",
+"[red]Error updating trusted IDs: {e}[/red]": "[red]Error al actualizar ID confiables: {e}[/red]",
+"[red]Error: Cannot specify both --hybrid and --v1[/red]": "[rojo]Error: no se puede especificar tanto --hybrid como --v1[/red]",
+"[red]Error: Cannot specify both --v2 and --hybrid[/red]": "[rojo]Error: no se pueden especificar tanto --v2 como --hybrid[/red]",
+"[red]Error: Cannot specify both --v2 and --v1[/red]": "[rojo]Error: no se pueden especificar tanto --v2 como --v1[/red]",
+"[red]Error: Configuration not available[/red]": "[rojo]Error: Configuración no disponible[/rojo]",
+"[red]Error: Failed to get daemon status: {error}[/red]": "[rojo]Error: No se pudo obtener el estado del demonio: {error}[/rojo]",
+"[red]Error: Info hash must be 40 hex characters[/red]": "[rojo]Error: el hash de información debe tener 40 caracteres hexadecimales[/rojo]",
+"[red]Error: Invalid torrent file: {torrent_file}[/red]": "[rojo]Error: Archivo torrent no válido: {torrent_file}[/red]",
+"[red]Error: Network configuration not available[/red]": "[rojo]Error: configuración de red no disponible[/rojo]",
+"[red]Error: Piece length must be a power of 2[/red]": "[rojo]Error: La longitud de la pieza debe ser una potencia de 2[/rojo]",
+"[red]Error: Piece length must be at least 16 KiB (16384 bytes)[/red]": "[rojo]Error: la longitud de la pieza debe ser de al menos 16 KiB (16384 bytes)[/rojo]",
+"[red]Error: Source directory is empty[/red]": "[rojo]Error: el directorio de origen está vacío[/rojo]",
+"[red]Error: Source path does not exist: {path}[/red]": "[rojo]Error: la ruta de origen no existe: {ruta}[/red]",
+"[red]Error: {e}[/red]": "[rojo]Error: {e}[/rojo]",
+"[red]Error:[/red] Invalid value for {key}: {value}": "[rojo]Error:[/rojo] Valor no válido para {clave}: {valor}",
+"[red]Error:[/red] Unknown configuration key: {key}": "[rojo]Error:[/rojo] Clave de configuración desconocida: {clave}",
+"[red]Export not available in daemon mode[/red]": "[rojo]Exportación no disponible en modo demonio[/rojo]",
+"[red]Failed to add magnet: {error}[/red]": "[rojo] No se pudo agregar el imán: {error}[/red]",
+"[red]Failed to cancel: {error}[/red]": "[rojo] No se pudo cancelar: {error}[/rojo]",
+"[red]Failed to clear active alerts: {e}[/red]": "[red] No se pudieron borrar las alertas activas: {e}[/red]",
+"[red]Failed to create session[/red]": "[rojo]Error al crear la sesión[/rojo]",
+"[red]Failed to disable proxy: {e}[/red]": "[rojo] No se pudo deshabilitar el proxy: {e}[/red]",
+"[red]Failed to force start: {error}[/red]": "[rojo] No se pudo forzar el inicio: {error}[/red]",
+"[red]Failed to get proxy status: {e}[/red]": "[rojo] No se pudo obtener el estado del proxy: {e}[/red]",
+"[red]Failed to load alert rules: {e}[/red]": "[rojo] No se pudieron cargar las reglas de alerta: {e}[/red]",
+"[red]Failed to load rules: {e}[/red]": "[rojo] No se pudieron cargar las reglas: {e}[/red]",
+"[red]Failed to pause: {error}[/red]": "[rojo] No se pudo pausar: {error}[/red]",
+"[red]Failed to reset options[/red]": "[rojo] No se pudieron restablecer las opciones [/rojo]",
+"[red]Failed to restart daemon[/red]": "[rojo]No se pudo reiniciar el demonio[/rojo]",
+"[red]Failed to resume: {error}[/red]": "[rojo] No se pudo reanudar: {error}[/red]",
+"[red]Failed to run tests: {e}[/red]": "[rojo] No se pudieron ejecutar las pruebas: {e}[/red]",
+"[red]Failed to save rules: {e}[/red]": "[rojo] No se pudieron guardar las reglas: {e}[/red]",
+"[red]Failed to set option[/red]": "[rojo]No se pudo configurar la opción[/rojo]",
+"[red]Failed to set proxy configuration: {e}[/red]": "[rojo] No se pudo establecer la configuración del proxy: {e}[/red]",
+"[red]Failed to start daemon. Cannot proceed without daemon.[/red]\n[yellow]Please check:[/yellow]\n 1. Daemon logs for startup errors\n 2. Port conflicts (check if port is already in use)\n 3. Permissions (ensure you have permission to start daemon)\n\n[cyan]To start daemon manually: 'btbt daemon start'[/cyan]": "[rojo] No se pudo iniciar el demonio. No se puede continuar sin demonio.[/red]\n[amarillo]Por favor marque:[/amarillo]\n 1. Daemon registra errores de inicio\n 2. Conflictos de puerto (verifique si el puerto ya está en uso)\n 3. Permisos (asegúrese de tener permiso para iniciar el demonio)\n\n[cian]Para iniciar el demonio manualmente: 'btbt daemon start'[/cian]",
+"[red]Failed to stop: {error}[/red]": "[rojo] No se pudo detener: {error}[/rojo]",
+"[red]Failed to test proxy: {e}[/red]": "[rojo] No se pudo probar el proxy: {e}[/red]",
+"[red]Failed to test rule: {e}[/red]": "[rojo] No se pudo probar la regla: {e}[/red]",
+"[red]Failed: {error}[/red]": "[rojo]Error: {error}[/rojo]",
+"[red]File not found: {e}[/red]": "[rojo]Archivo no encontrado: {e}[/red]",
+"[red]IP filter not initialized. Please enable it in configuration.[/red]": "[rojo] Filtro IP no inicializado. Habilítelo en la configuración.[/red]",
+"[red]IP filter not initialized.[/red]": "[rojo]Filtro IP no inicializado.[/rojo]",
+"[red]IPFS protocol not available[/red]": "[rojo]Protocolo IPFS no disponible[/rojo]",
+"[red]Import not available in daemon mode[/red]": "[rojo]Importación no disponible en modo demonio[/rojo]",
+"[red]Invalid IP address: {ip}[/red]": "[rojo]Dirección IP no válida: {ip}[/red]",
+"[red]Invalid info hash format[/red]": "[rojo]Formato hash de información no válido[/rojo]",
+"[red]Invalid info hash: {hash}[/red]": "[rojo]Hash de información no válido: {hash}[/red]",
+"[red]Invalid magnet link: {e}[/red]": "[red]Enlace magnético no válido: {e}[/red]",
+"[red]Invalid public key: {e}[/red]": "[rojo]Clave pública no válida: {e}[/red]",
+"[red]Invalid value for {key}: {error}[/red]": "[rojo]Valor no válido para {clave}: {error}[/rojo]",
+"[red]Key file does not exist: {path}[/red]": "[rojo]El archivo de clave no existe: {ruta}[/red]",
+"[red]Key path must be a file: {path}[/red]": "[rojo]La ruta clave debe ser un archivo: {ruta}[/red]",
+"[red]Metrics error: {e}[/red]": "[rojo]Error de métricas: {e}[/red]",
+"[red]No stats found for CID: {cid}[/red]": "[rojo]No se encontraron estadísticas para CID: {cid}[/red]",
+"[red]Path does not exist: {path}[/red]": "[rojo]La ruta no existe: {ruta}[/red]",
+"[red]Path must be a file or directory: {path}[/red]": "[rojo]La ruta debe ser un archivo o directorio: {ruta}[/red]",
+"[red]Peer {peer_id} not found in allowlist[/red]": "[red]El compañero {peer_id} no se encuentra en la lista de permitidos[/red]",
+"[red]Proxy error: {e}[/red]": "[rojo]Error de proxy: {e}[/rojo]",
+"[red]Proxy host and port must be configured[/red]": "[rojo]El host y el puerto del proxy deben estar configurados[/rojo]",
+"[red]Rule not found: {name}[/red]": "[rojo]Regla no encontrada: {nombre}[/rojo]",
+"[red]Specify CID or use --all[/red]": "[rojo]Especifique CID o use --all[/red]",
+"[red]Torrent not found: {hash}[/red]": "[rojo]Torrent no encontrado: {hash}[/red]",
+"[red]Unexpected error during resume: {e}[/red]": "[rojo]Error inesperado durante el currículum: {e}[/red]",
+"[red]Unknown configuration key: {key}[/red]": "[rojo]Clave de configuración desconocida: {clave}[/rojo]",
+"[red]Validation error: {e}[/red]": "[rojo]Error de validación: {e}[/red]",
+"[red]{msg}[/red]": "[rojo]{msg}[/rojo]",
+"[red]✗ Failed to remove port mapping[/red]": "[rojo]✗ No se pudo eliminar la asignación de puertos[/rojo]",
+"[red]✗ Port mapping failed[/red]": "[rojo]✗ Error en la asignación de puertos[/rojo]",
+"[red]✗ Proxy connection test failed[/red]": "[rojo]✗ La prueba de conexión de proxy falló[/rojo]",
+"[red]✗[/red] Daemon is already running with PID {pid}": "[rojo]✗[/rojo] Daemon ya se está ejecutando con PID {pid}",
+"[red]✗[/red] Daemon process (PID {pid}) crashed during startup (after {elapsed:.1f}s)": "[rojo]✗[/rojo] El proceso demonio (PID {pid}) falló durante el inicio (después de {elapsed:.1f}s)",
+"[red]✗[/red] Daemon process (PID {pid}) exited immediately after starting": "[rojo]✗[/rojo] El proceso demonio (PID {pid}) salió inmediatamente después de iniciar",
+"[red]✗[/red] Failed to add filter rule: {ip_range}": "[rojo]✗[/rojo] No se pudo agregar la regla de filtro: {ip_range}",
+"[red]✗[/red] Failed to load rules from {file_path}": "[rojo]✗[/rojo] No se pudieron cargar las reglas desde {file_path}",
+"[red]✗[/red] Failed to start daemon: {e}": "[rojo]✗[/rojo] No se pudo iniciar el demonio: {e}",
+"[red]✗[/red] Failed to update filter lists": "[rojo]✗[/rojo] No se pudieron actualizar las listas de filtros",
+"[yellow]1. Network Connectivity[/yellow]": "[amarillo]1. Conectividad de red[/amarillo]",
+"[yellow]API key not found in config, cannot get detailed status[/yellow]": "[amarillo]La clave API no se encuentra en la configuración, no se puede obtener el estado detallado[/amarillo]",
+"[yellow]Active Protocol:[/yellow] None (not discovered)": "[amarillo]Protocolo activo:[/amarillo] Ninguno (no descubierto)",
+"[yellow]Allowlist is empty[/yellow]": "[amarillo]La lista de permitidos está vacía[/amarillo]",
+"[yellow]Authenticated swarm setting updated (configuration not persisted - no config file)[/yellow]": "[amarillo] Configuración de enjambre autenticado actualizada (la configuración no persiste, no hay archivo de configuración) [/amarillo]",
+"[yellow]Authenticated swarm setting updated (test mode, write skipped)[/yellow]": "[amarillo] Configuración de enjambre autenticado actualizada (modo de prueba, escritura omitida) [/amarillo]",
+"[yellow]Authenticated swarms not configured[/yellow]": "[amarillo]Enjambres autenticados no configurados[/amarillo]",
+"[yellow]Automatic repair not implemented[/yellow]": "[amarillo]Reparación automática no implementada[/amarillo]",
+"[yellow]CA certificates path set to {path} (configuration not persisted - no config file)[/yellow]": "[amarillo] Ruta de los certificados de CA establecida en {ruta} (la configuración no persiste, no hay archivo de configuración) [/amarillo]",
+"[yellow]CA certificates path set to {path} (skipped write in test mode)[/yellow]": "[amarillo] Ruta de los certificados de CA establecida en {ruta} (escritura omitida en modo de prueba) [/amarillo]",
+"[yellow]Checkpoint cannot be auto-resumed - no torrent source found[/yellow]": "[amarillo]El punto de control no se puede reanudar automáticamente: no se encontró ninguna fuente de torrent[/amarillo]",
+"[yellow]Checkpoint for {hash} is missing or invalid[/yellow]": "[amarillo]Falta el punto de control para {hash} o no es válido[/amarillo]",
+"[yellow]Checkpoint missing/invalid[/yellow]": "[amarillo]Punto de control faltante/no válido[/amarillo]",
+"[yellow]Client certificate set (configuration not persisted - no config file)[/yellow]": "[amarillo] Conjunto de certificados de cliente (la configuración no persiste, no hay archivo de configuración) [/amarillo]",
+"[yellow]Client certificate set (skipped write in test mode)[/yellow]": "[amarillo] Conjunto de certificados de cliente (escritura omitida en modo de prueba) [/amarillo]",
+"[yellow]Configuration changes require daemon restart.[/yellow]": "[amarillo] Los cambios de configuración requieren el reinicio del demonio. [/amarillo]",
+"[yellow]Could not deselect: {error}[/yellow]": "[amarillo] No se pudo anular la selección: {error}[/amarillo]",
+"[yellow]Could not get detailed status via IPC[/yellow]": "[amarillo]No se pudo obtener el estado detallado a través de IPC[/amarillo]",
+"[yellow]Could not save to config file: {error}[/yellow]": "[amarillo] No se pudo guardar en el archivo de configuración: {error}[/amarillo]",
+"[yellow]Disk I/O manager not running. Statistics unavailable.[/yellow]": "[amarillo] El administrador de E/S de disco no se está ejecutando. Estadísticas no disponibles.[/amarillo]",
+"[yellow]Dry run: Would clean chunks older than {days} days[/yellow]": "[amarillo] Ejecución en seco: limpiaría trozos de más de {días} días[/amarillo]",
+"[yellow]External IP not available[/yellow]": "[amarillo]IP externa no disponible[/amarillo]",
+"[yellow]External IP:[/yellow] Not available": "[amarillo]IP externa:[/amarillo] No disponible",
+"[yellow]Failed to generate tonic link[/yellow]": "[amarillo] No se pudo generar el enlace tónico [/amarillo]",
+"[yellow]Failed to move torrent[/yellow]": "[amarillo] No se pudo mover el torrent[/amarillo]",
+"[yellow]Failed to refresh checkpoint for {hash}[/yellow]": "[amarillo] No se pudo actualizar el punto de control de {hash}[/amarillo]",
+"[yellow]Failed to reload checkpoint for {hash}[/yellow]": "[amarillo] No se pudo recargar el punto de control de {hash}[/amarillo]",
+"[yellow]Fast resume is disabled[/yellow]": "[amarillo] La reanudación rápida está deshabilitada [/amarillo]",
+"[yellow]Found checkpoint for: {name}[/yellow]": "[amarillo]Punto de control encontrado para: {nombre}[/amarillo]",
+"[yellow]Found checkpoint for: {torrent_name}[/yellow]": "[amarillo]Punto de control encontrado para: {torrent_name}[/amarillo]",
+"[yellow]Full rehash not implemented in CLI; use resume to trigger piece verification[/yellow]": "[amarillo]Repetición completa no implementada en CLI; usar currículum para activar la verificación de piezas[/amarillo]",
+"[yellow]IP filter not initialized or disabled.[/yellow]": "[amarillo]Filtro IP no inicializado o deshabilitado.[/amarillo]",
+"[yellow]Integrity verification failed: {count} pieces failed[/yellow]": "[amarillo] Falló la verificación de integridad: {count} piezas falló[/amarillo]",
+"[yellow]NAT Status[/yellow]": "[amarillo]Estado NAT[/amarillo]",
+"[yellow]Network optimizer not available[/yellow]": "[amarillo]Optimizador de red no disponible[/amarillo]",
+"[yellow]Network statistics not available[/yellow]": "[amarillo]Estadísticas de red no disponibles[/amarillo]",
+"[yellow]No active alerts[/yellow]": "[amarillo]No hay alertas activas[/amarillo]",
+"[yellow]No alert rules defined[/yellow]": "[amarillo]No hay reglas de alerta definidas[/amarillo]",
+"[yellow]No alias found for peer {peer_id}[/yellow]": "[amarillo]No se encontró ningún alias para el par {peer_id}[/amarillo]",
+"[yellow]No aliases found in allowlist[/yellow]": "[amarillo]No se encontraron alias en la lista de permitidos[/amarillo]",
+"[yellow]No authenticated swarms configuration found[/yellow]": "[amarillo] No se encontró ninguna configuración de enjambres autenticados [/amarillo]",
+"[yellow]No cached scrape results[/yellow]": "[amarillo] No hay resultados de raspado almacenados en caché [/amarillo]",
+"[yellow]No checkpoint found for {hash}[/yellow]": "[amarillo]No se encontró ningún punto de control para {hash}[/amarillo]",
+"[yellow]No checkpoint found for {info_hash}[/yellow]": "[amarillo]No se encontró ningún punto de control para {info_hash}[/amarillo]",
+"[yellow]No chunks in cache[/yellow]": "[amarillo]No hay fragmentos en caché[/amarillo]",
+"[yellow]No config file found - configuration not persisted[/yellow]": "[amarillo] No se encontró ningún archivo de configuración: la configuración no persiste [/amarillo]",
+"[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]": "[amarillo]No hay lista de archivos disponible dentro de {tiempo de espera}s, continuando con la selección predeterminada.[/amarillo]",
+"[yellow]No filter URLs configured.[/yellow]": "[amarillo]No hay URL de filtro configuradas.[/amarillo]",
+"[yellow]No filter rules configured.[/yellow]": "[amarillo]No hay reglas de filtrado configuradas.[/amarillo]",
+"[yellow]No optimizations were applied (already optimal or unsupported)[/yellow]": "[amarillo]No se aplicaron optimizaciones (ya son óptimas o no son compatibles)[/amarillo]",
+"[yellow]No performance action specified[/yellow]": "[amarillo]No se ha especificado ninguna acción de rendimiento[/amarillo]",
+"[yellow]No recover action specified[/yellow]": "[amarillo]No se ha especificado ninguna acción de recuperación[/amarillo]",
+"[yellow]No resume data found in checkpoint[/yellow]": "[amarillo] No se encontraron datos del currículum en el punto de control [/amarillo]",
+"[yellow]No security action specified[/yellow]": "[amarillo]No se ha especificado ninguna acción de seguridad[/amarillo]",
+"[yellow]No security configuration loaded[/yellow]": "[amarillo]No se ha cargado ninguna configuración de seguridad[/amarillo]",
+"[yellow]No valid indices, keeping default selection.[/yellow]": "[amarillo]No hay índices válidos, se mantiene la selección predeterminada.[/amarillo]",
+"[yellow]Non-interactive mode, starting fresh download[/yellow]": "[amarillo]Modo no interactivo, iniciando una nueva descarga[/amarillo]",
+"[yellow]Note: This change is temporary and will be lost on restart. Use config file for persistent changes.[/yellow]": "[amarillo]Nota: Este cambio es temporal y se perderá al reiniciar. Utilice el archivo de configuración para cambios persistentes.[/amarillo]",
+"[yellow]Note: Update config file to persist locale setting[/yellow]": "[amarillo]Nota: actualice el archivo de configuración para conservar la configuración regional[/amarillo]",
+"[yellow]Note:[/yellow] Configuration change is runtime-only": "[amarillo]Nota:[/amarillo] El cambio de configuración es solo en tiempo de ejecución",
+"[yellow]Optimization cancelled[/yellow]": "[amarillo]Optimización cancelada[/amarillo]",
+"[yellow]Peer {peer_id} not found in allowlist[/yellow]": "[amarillo] El par {peer_id} no se encuentra en la lista de permitidos [/amarillo]",
+"[yellow]Please provide the original torrent file or magnet link[/yellow]": "[amarillo] Proporcione el archivo torrent original o el enlace magnético [/amarillo]",
+"[yellow]Please use --v2 or --hybrid flags for now.[/yellow]": "[amarillo] Utilice las banderas --v2 o --hybrid por ahora.[/amarillo]",
+"[yellow]Proxy configuration not found[/yellow]": "[amarillo]Configuración de proxy no encontrada[/amarillo]",
+"[yellow]Proxy configuration updated (skipped write in test mode)[/yellow]": "[amarillo]Configuración de proxy actualizada (escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]Proxy has been disabled (skipped write in test mode)[/yellow]": "[amarillo] El proxy ha sido deshabilitado (se omitió la escritura en modo de prueba) [/amarillo]",
+"[yellow]Proxy is not enabled[/yellow]": "[amarillo]El proxy no está habilitado[/amarillo]",
+"[yellow]Real-time monitoring not yet implemented[/yellow]": "[amarillo]Monitoreo en tiempo real aún no implementado[/amarillo]",
+"[yellow]Refresh completed with warnings[/yellow]": "[amarillo]Actualización completada con advertencias[/amarillo]",
+"[yellow]Resume data validation found issues:[/yellow]": "[amarillo] Problemas encontrados al reanudar la validación de datos:[/amarillo]",
+"[yellow]Rich not available, starting fresh download[/yellow]": "[amarillo]Rich no disponible, iniciando una nueva descarga[/amarillo]",
+"[yellow]Rule not found: {ip_range}[/yellow]": "[amarillo]Regla no encontrada: {ip_range}[/amarillo]",
+"[yellow]SSL certificate verification disabled (not recommended). Configuration saved to {config_file}[/yellow]": "[amarillo]Verificación del certificado SSL deshabilitada (no recomendado). Configuración guardada en {config_file}[/amarillo]",
+"[yellow]SSL certificate verification disabled (not recommended, configuration not persisted - no config file)[/yellow]": "[amarillo]Verificación del certificado SSL deshabilitada (no recomendado, configuración no persistente - no hay archivo de configuración)[/amarillo]",
+"[yellow]SSL certificate verification disabled (not recommended, skipped write in test mode)[/yellow]": "[amarillo]Verificación del certificado SSL deshabilitada (no recomendado, escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]SSL certificate verification enabled (configuration not persisted - no config file)[/yellow]": "[amarillo]Verificación del certificado SSL habilitada (la configuración no persiste, no hay archivo de configuración)[/amarillo]",
+"[yellow]SSL certificate verification enabled (skipped write in test mode)[/yellow]": "[amarillo]Verificación del certificado SSL habilitada (escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]SSL for peers disabled (configuration not persisted - no config file)[/yellow]": "[amarillo]SSL para pares deshabilitado (la configuración no persiste - no hay archivo de configuración)[/amarillo]",
+"[yellow]SSL for peers disabled (skipped write in test mode)[/yellow]": "[amarillo]SSL para pares deshabilitado (escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]SSL for peers enabled (experimental, configuration not persisted - no config file)[/yellow]": "[amarillo]SSL para pares habilitado (experimental, configuración no persistente - sin archivo de configuración)[/amarillo]",
+"[yellow]SSL for peers enabled (experimental, skipped write in test mode)[/yellow]": "[amarillo]SSL para pares habilitado (experimental, escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]SSL for trackers disabled (configuration not persisted - no config file)[/yellow]": "[amarillo]SSL para rastreadores deshabilitado (la configuración no persiste - no hay archivo de configuración)[/amarillo]",
+"[yellow]SSL for trackers disabled (skipped write in test mode)[/yellow]": "[amarillo]SSL para rastreadores deshabilitado (escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]SSL for trackers enabled (configuration not persisted - no config file)[/yellow]": "[amarillo]SSL para rastreadores habilitado (la configuración no persiste - no hay archivo de configuración)[/amarillo]",
+"[yellow]SSL for trackers enabled (skipped write in test mode)[/yellow]": "[amarillo]SSL para rastreadores habilitado (escritura omitida en modo de prueba)[/amarillo]",
+"[yellow]Select failed: {error}[/yellow]": "[amarillo] Error al seleccionar: {error}[/amarillo]",
+"[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]": "[amarillo]Establecer --download-limit/--upload-limit para límites globales; por igual a través de configuración[/amarillo]",
+"[yellow]Starting fresh download[/yellow]": "[amarillo]Iniciando nueva descarga[/amarillo]",
+"[yellow]TLS protocol version set to {version} (configuration not persisted - no config file)[/yellow]": "[amarillo] Versión del protocolo TLS configurada en {versión} (la configuración no persiste, no hay archivo de configuración) [/amarillo]",
+"[yellow]TLS protocol version set to {version} (skipped write in test mode)[/yellow]": "[amarillo] Versión del protocolo TLS configurada en {versión} (escritura omitida en modo de prueba) [/amarillo]",
+"[yellow]The daemon process crashed during initialization.[/yellow]": "[amarillo]El proceso del demonio falló durante la inicialización.[/amarillo]",
+"[yellow]The daemon process exited unexpectedly. Check daemon logs for error details.[/yellow]": "[amarillo] El proceso del demonio se cerró inesperadamente. Consulte los registros del demonio para obtener detalles del error.[/amarillo]",
+"[yellow]This usually indicates a configuration error, missing dependency, or initialization failure.[/yellow]": "[amarillo]Esto generalmente indica un error de configuración, falta de dependencia o falla de inicialización.[/amarillo]",
+"[yellow]Timeout waiting for daemon (last status: {last_status})[/yellow]": "[amarillo]Tiempo de espera de espera para el demonio (último estado: {last_status})[/amarillo]",
+"[yellow]To see errors in the terminal, run:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]": "[amarillo]Para ver errores en la terminal, ejecute:[/amarillo] [dim]uv run btbt daemon start --foreground[/dim]",
+"[yellow]Toggle encryption via --enable-encryption/--disable-encryption on download/magnet[/yellow]": "[amarillo] Alternar cifrado mediante --enable-encryption/--disable-encryption en descarga/imán[/amarillo]",
+"[yellow]Torrent not found in queue[/yellow]": "[amarillo]Torrent no encontrado en la cola[/amarillo]",
+"[yellow]Torrent not found or not active. Resume data will be automatically saved when torrent completes.[/yellow]": "[amarillo]Torrent no encontrado o no activo. Los datos del currículum se guardarán automáticamente cuando se complete el torrent.[/amarillo]",
+"[yellow]Torrent not found[/yellow]": "[amarillo]Torrent no encontrado[/amarillo]",
+"[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load or --save[/yellow]": "[amarillo] Utilice --list/--list-active, --add, --remove, --clear-active, --test, --load o --save[/amarillo]",
+"[yellow]Use -v flag for more details or try --foreground to see error output[/yellow]": "[amarillo]Utilice el indicador -v para obtener más detalles o pruebe --foreground para ver el resultado del error[/amarillo]",
+"[yellow]Warning: Checkpoint save failed[/yellow]": "[amarillo]Advertencia: Error al guardar el punto de control[/amarillo]",
+"[yellow]Warning: Configuration changes require daemon restart, but restart was skipped.[/yellow]": "[amarillo]Advertencia: los cambios de configuración requieren el reinicio del demonio, pero se omitió el reinicio.[/amarillo]",
+"[yellow]Warning: Daemon is running. Diagnostics will test local session which may cause port conflicts.[/yellow]\n[dim]Consider stopping the daemon first: 'btbt daemon exit'[/dim]\n": "[amarillo]Advertencia: Daemon se está ejecutando. Los diagnósticos probarán la sesión local, lo que puede causar conflictos de puertos.[/amarillo]\n[dim] Considere detener el demonio primero: 'btbt daemon exit'[/dim]",
+"[yellow]Warning: Error saving checkpoint: {error}[/yellow]": "[amarillo]Advertencia: Error al guardar el punto de control: {error}[/amarillo]",
+"[yellow]Warning: Error stopping session: {e}[/yellow]": "[amarillo]Advertencia: Error al detener la sesión: {e}[/amarillo]",
+"[yellow]Warning: Failed to save checkpoint: {error}[/yellow]": "[amarillo]Advertencia: No se pudo guardar el punto de control: {error}[/amarillo]",
+"[yellow]Warning: Failed to select files: {error}[/yellow]": "[amarillo]Advertencia: No se pudieron seleccionar archivos: {error}[/amarillo]",
+"[yellow]Warning: Failed to set queue priority: {error}[/yellow]": "[amarillo]Advertencia: No se pudo establecer la prioridad de la cola: {error}[/amarillo]",
+"[yellow]Warning: IPC client not available[/yellow]": "[amarillo]Advertencia: cliente IPC no disponible[/amarillo]",
+"[yellow]Warning: SSL certificate verification is disabled while SSL is used in strict mode[/yellow]": "[amarillo]Advertencia: la verificación del certificado SSL está deshabilitada mientras SSL se usa en modo estricto[/amarillo]",
+"[yellow]Warning: V1 torrent generation is not yet implemented.[/yellow]": "[amarillo]Advertencia: la generación de torrent V1 aún no está implementada.[/amarillo]",
+"[yellow]Warning: certificate verification is disabled while SSL is in strict posture[/yellow]": "[amarillo]Advertencia: la verificación del certificado está deshabilitada mientras SSL está en postura estricta[/amarillo]",
+"[yellow]Would delete {count} checkpoints older than {days} days:[/yellow]": "[amarillo]Eliminaría {count} puntos de control con más de {días} días:[/amarillo]",
+"[yellow]{key} is not set[/yellow]": "[amarillo]{clave} no está configurada[/amarillo]",
+"[yellow]⚠[/yellow] Could not save daemon config to config file: {e}": "[amarillo]⚠[/amarillo] No se pudo guardar la configuración del demonio en el archivo de configuración: {e}",
+"[yellow]⚠[/yellow] Daemon process started (PID {pid}) but may not be fully ready yet": "[amarillo]⚠[/amarillo] El proceso del demonio se inició (PID {pid}) pero es posible que aún no esté completamente listo",
+"[yellow]⚠[/yellow] Daemon startup timeout after {timeout:.1f}s (last status: {last_status})": "[amarillo]⚠[/amarillo] Tiempo de espera de inicio del demonio después de {timeout:.1f}s (último estado: {last_status})",
+"[yellow]⚠[/yellow] {errors} errors encountered": "[amarillo]⚠[/amarillo] {errores} errores encontrados",
+"[yellow]✓[/yellow] Xet protocol disabled": "[amarillo] ✓[/amarillo] Protocolo Xet deshabilitado",
+"[yellow]✓[/yellow] uTP transport disabled": "[amarillo] ✓[/amarillo] transporte uTP deshabilitado",
+"_get_executor() returned: executor=%s, is_daemon=%s": "_get_executor() devolvió: ejecutor=%s, is_daemon=%s",
+"aiortc not installed": "aiortc no instalado",
+"disabled": "desactivado",
+"enable_dht={value}": "enable_dht={valor}",
+"enable_pex={value}": "enable_pex={valor}",
+"enabled": "activado",
+"failed": "fallido",
+"fell": "cayó",
+"http://tracker.example.com:8080/announce": "http://tracker.example.com:8080/anunciar",
+"no": "No",
+"none": "ninguno",
+"not ready yet": "aún no estoy listo",
+"peers": "colegas",
+"pieces": "piezas",
+"replace: file must be a full valid document; merge: deep-merge into existing target TOML then validate": "reemplazar: el archivo debe ser un documento válido completo; fusionar: realizar una fusión profunda en el TOML de destino existente y luego validar",
+"rose": "rosa",
+"succeeded": "tuvo éxito",
+"tonic share requires the daemon. Start it with: btbt daemon start": "La parte tónica requiere el demonio. Empiece con: btbt daemon start",
+"uTP": "UTP",
+"uTP (uTorrent Transport Protocol) Options:\n\nuTP provides reliable, ordered delivery over UDP with delay-based congestion control (BEP 29).\nUseful for better performance on networks with high latency or packet loss.": "Opciones de uTP (Protocolo de transporte uTorrent):\n\nuTP proporciona entrega ordenada y confiable a través de UDP con control de congestión basado en demoras (BEP 29).\nÚtil para un mejor rendimiento en redes con alta latencia o pérdida de paquetes.",
+"uTP Configuration": "Configuración uTP",
+"uTP config": "configuración uTP",
+"uTP configuration reset to defaults via CLI": "Restablecimiento de la configuración de uTP a los valores predeterminados a través de CLI",
+"uTP configuration updated: %s = %s": "configuración de uTP actualizada: %s = %s",
+"uTP transport disabled via CLI": "Transporte uTP deshabilitado a través de CLI",
+"uTP transport enabled": "transporte uTP habilitado",
+"uTP transport enabled via CLI": "Transporte uTP habilitado a través de CLI",
+"unknown": "desconocido",
+"unlimited": "ilimitado",
+"yes": "Sí",
+"{connection} Torrents: {torrents} Active: {active} Paused: {paused} Seeding: {seeding} D: {download}B/s U: {upload}B/s": "{conexión} Torrentes: {torrents} Activo: {activo} En pausa: {pausado} Siembra: {siembra} D: {descargar}B/s U: {cargar}B/s",
+"{graph_tab_id} - Data provider configuration error": "{graph_tab_id}: error de configuración del proveedor de datos",
+"{graph_tab_id} - Data provider not available": "{graph_tab_id}: proveedor de datos no disponible",
+"{hours:.1f}h ago": "Hace {horas:.1f}h",
+"{key} = {value}": "{clave} = {valor}",
+"{key}: {value}": "{clave}: {valor}",
+"{minutes:.0f}m ago": "Hace {minutos:.0f}m",
+"{msg}\n\nPID file path: {path}": "{mensaje}\n\nRuta del archivo PID: {ruta}",
+"{seconds:.0f}s ago": "Hace {segundos:.0f}s",
+"{sub_tab} configuration - Coming soon": "Configuración de {sub_tab} - Próximamente",
+"{sub_tab} content for torrent {hash}... - Coming soon": "{sub_tab} contenido para torrent {hash}.... - Próximamente",
+"{type} Configuration": "{tipo} Configuración",
+"↑ Rate": "↑ Tarifa",
+"↑ Speed": "↑ Velocidad",
+"↓ Rate": "↓ Tarifa",
+"↓ Speed": "↓ Velocidad",
+"≥ 80% available": "≥ 80% disponible",
+"⏸ Pause": "⏸ Pausa",
+"▶ Resume": "▶ Reanudar",
+"⚠️ Daemon restart required to apply changes.\n": "⚠️ Es necesario reiniciar el demonio para aplicar los cambios.",
+"✓ Configuration is valid": "✓ La configuración es válida",
+"✓ No system compatibility warnings": "✓ No hay advertencias de compatibilidad del sistema",
+"✓ Verify": "✓ Verificar",
+"✗ Configuration validation failed: {e}": "✗ Falló la validación de la configuración: {e}",
+"📊 Refresh PEX": "📊 Actualizar PEX",
+"📥 Export State": "📥 Estado de exportación",
+"🔄 Reannounce": "🔄 Renunciar",
+"🔍 Rehash": "🔍 Refrito",
+"🗑 Remove": "🗑 Quitar"
+}
diff --git a/ccbt/i18n/locale_data/es_val_0.json b/ccbt/i18n/locale_data/es_val_0.json
new file mode 100644
index 00000000..775799f4
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_0.json
@@ -0,0 +1,191 @@
+[
+ "Habilitado (falta dependencia)",
+ "Habilitado (no iniciado)",
+ "Cifrar la copia de seguridad con clave generada",
+ "Cifrando copia de seguridad…",
+ "Solicitudes duplicadas en fase final",
+ "Umbral de fase final (0..1)",
+ "Introduzca la URL del tracker",
+ "Introduzca la ruta…",
+ "Introduzca el directorio donde deben descargarse los archivos:\n\nDéjelo vacío para usar el directorio actual.",
+ "Introduzca la ruta de un archivo .torrent o un enlace magnet:\n\nEjemplos:\n /ruta/al/archivo.torrent\n magnet:?xt=urn:btih:...",
+ "Introduzca la ruta del archivo torrent o el enlace magnet",
+ "Introduzca la ruta del archivo torrent o el enlace magnet:",
+ "Error",
+ "Error: {error}",
+ "Errores",
+ "Velocidad de lectura estimada",
+ "Velocidad de escritura estimada",
+ "Eventos",
+ "Tasa de expulsión: {rate:.2f} /s",
+ "Se superó el tiempo máximo de espera (%.1fs) para la disponibilidad del demonio",
+ "Excelente",
+ "Existe",
+ "Hash de información esperado (hexadecimal)",
+ "Tipo esperado: {type_name}",
+ "Exportación completada",
+ "Exportando punto de control…",
+ "Solicitudes fallidas",
+ "Regular",
+ "Obteniendo metadatos…",
+ "Obteniendo la lista de archivos para la selección. Puede tardar un momento.",
+ "Campo",
+ "Navegador de archivos",
+ "Explorador de archivos: proveedor de datos o ejecutor no disponible",
+ "Explorador de archivos: error: {error}",
+ "Explorador de archivos: seleccione archivos para crear torrents",
+ "Explorador de archivos",
+ "El archivo debe tener extensión .torrent: %s",
+ "Archivo no encontrado: %s",
+ "Archivo {number}",
+ "Archivo: {name}\nPuerto: {port}\nBytes servidos: {bytes_served}\nClientes: {clients}\nÚltimo rango: {start} - {end}\nBytes legibles: {available}\nÚltimo error: {error}",
+ "Archivos en el torrent {hash}…",
+ "Archivos: {count}",
+ "Error al actualizar el filtro",
+ "Carpeta no encontrada: {folder}",
+ "Carpeta: {name}",
+ "Forzar anuncio",
+ "Forzar cierre sin apagado ordenado",
+ "Se encontraron {count} posibles problemas",
+ "Ruta completa",
+ "La edición completa de la configuración requiere ir a la pantalla de configuración global",
+ "General",
+ "Configuración general: proveedor de datos o ejecutor no disponible",
+ "Generar nueva clave de API",
+ "Nueva clave de API generada para el demonio",
+ "Generando torrent {format}…",
+ "GitHub oscuro",
+ "Global",
+ "Configuración global",
+ "Pares conectados (global)",
+ "KPI globales",
+ "Los datos de KPI globales no están disponibles en este modo.",
+ "Indicadores clave de rendimiento globales",
+ "Métricas globales de torrent",
+ "Configuración global",
+ "Límite global de descarga (KiB/s)",
+ "Límite global de subida (KiB/s)",
+ "Bueno",
+ "Tiempo de apagado agotado; forzando detención",
+ "Gráficos",
+ "Gruvbox",
+ "Error HTTP al comprobar el estado del demonio en %s: %s (estado %d)",
+ "Tamaño de trozo para hash",
+ "Hilos de verificación de trozos",
+ "Salud",
+ "Pantalla de ayuda",
+ "Alto",
+ "Tendencias históricas",
+ "Host para la interfaz web",
+ "Dirección IP",
+ "Filtro IP no disponible",
+ "IP:Puerto",
+ "IPCClient.get_daemon_pid: comprobando pid_file=%s (home_dir=%s, existe=%s)",
+ "Opciones del protocolo IPFS:\n\nIPFS permite almacenamiento direccionado por contenido y uso compartido entre pares.\nTras la descarga se puede acceder al contenido mediante el CID de IPFS.",
+ "Gestión de IPFS",
+ "Ocioso",
+ "Inactivo",
+ "Incluir el valor en tiempo de ejecución efectivo de la configuración cargada (archivo + entorno)",
+ "Aumentar verbosidad (-v: detallado, -vv: depuración, -vvv: trazas)",
+ "Índice",
+ "Información",
+ "Hashes de información",
+ "Hash de información copiado al portapapeles",
+ "Hash de información: {hash}",
+ "Tasa inicial",
+ "Tasa de envío inicial",
+ "Dirección IP no válida: {error}",
+ "Rango IP no válido: {ip_range}",
+ "Configuración no válida tras la fusión: {e}",
+ "Configuración no válida: el nivel superior debe ser un objeto",
+ "Configuración no válida: {e}",
+ "Formato de hash de información no válido",
+ "Formato de hash de información no válido: %s",
+ "Formato de hash de información no válido: {hash}",
+ "Longitud de hash no válida en el enlace magnet",
+ "Configuración regional '{current_locale}' no válida. Se usará 'en'. Disponibles: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu",
+ "Enlace magnet no válido: falta el parámetro 'xt=urn:btih:'",
+ "Formato de enlace magnet no válido",
+ "Formato de enlace magnet no válido: debe comenzar por 'magnet:?'",
+ "Selección de par no válida",
+ "Perfil '{name}' no válido: {errors}",
+ "Plantilla '{name}' no válida: {errors}",
+ "Formato de URL de tracker no válido. Debe comenzar por http://, https:// o udp://",
+ "Selección de tracker no válida",
+ "Atajos de teclado",
+ "Idioma",
+ "Último error",
+ "Última actualización",
+ "Última muestra {age}",
+ "Latencia",
+ "Claro",
+ "Modo claro",
+ "Listar configuraciones regionales disponibles",
+ "Interfaz de escucha",
+ "Puerto de escucha",
+ "Cargando configuración…",
+ "Cargando lista de archivos…",
+ "Cargando métricas de pares…",
+ "Cargando métricas de selección de piezas…",
+ "Cargando línea temporal del enjambre…",
+ "Cargando información del torrent…",
+ "Información del nodo local",
+ "Bajo",
+ "Tamaño de caché MMap (MB)",
+ "MTU",
+ "Comando magnet: comprobación de archivo PID: existe=%s, ruta=%s",
+ "El enlace magnet debe contener el parámetro 'xt=urn:btih:'",
+ "El enlace magnet debe comenzar por 'magnet:?'",
+ "Tasa máxima",
+ "Retransmisiones máx.",
+ "Tamaño máx. de ventana",
+ "Máximo",
+ "Tamaño máx. de paquete UDP",
+ "Tamaño máx. de bloque (KiB)",
+ "Tasa máx. de descarga para este torrent",
+ "Máximo de pares globales",
+ "Máximo de pares por torrent",
+ "Tamaño máx. de ventana de recepción",
+ "Intentos máx. de retransmisión",
+ "Tasa máx. de envío",
+ "Tasa máx. de subida para este torrent",
+ "Multimedia",
+ "Reproducción multimedia",
+ "Transmisión multimedia iniciada.",
+ "Transmisión multimedia detenida.",
+ "Medio",
+ "Memoria",
+ "Cargando metadatos. La selección de archivos aparecerá cuando esté disponible.",
+ "Explorador de métricas",
+ "Intervalo de métricas (s)",
+ "Intervalo de métricas: {interval}s",
+ "Puerto de métricas",
+ "Migrando formato de punto de control de {from_fmt} a {to_fmt}…",
+ "Migración completada",
+ "Tasa mínima",
+ "Tamaño mín. de bloque (KiB)",
+ "Tasa mín. de envío",
+ "Modo",
+ "Modelo '{model}' no encontrado en Config",
+ "Modificado",
+ "Monitorización",
+ "Monokai",
+ "N/D",
+ "Opciones de NAT traversal:\n\nEl NAT traversal (NAT-PMP/UPnP) asigna puertos en su router automáticamente.\nPermite que los pares se conecten directamente y mejora la velocidad de descarga.",
+ "Gestión NAT",
+ "Nombre: {name}",
+ "Navegación",
+ "Menú de navegación",
+ "Configuración de red",
+ "Recomendaciones de optimización de red",
+ "Rendimiento de red",
+ "Configuración de red (conexiones, tiempos de espera, límites de velocidad)",
+ "Configuración de red: proveedor de datos o ejecutor no disponible",
+ "Calidad de red",
+ "Calidad de red: error: {error}",
+ "Nunca",
+ "Siguiente",
+ "Paso siguiente",
+ "Aún no hay métricas DHT por torrent.",
+ "No se encontró archivo PID; comprobando demonio mediante _get_executor()"
+]
\ No newline at end of file
diff --git a/ccbt/i18n/locale_data/es_val_1.json b/ccbt/i18n/locale_data/es_val_1.json
new file mode 100644
index 00000000..8e0d55cc
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_1.json
@@ -0,0 +1,191 @@
+[
+ "Sin acceso",
+ "No hay transmisión activa que detener.",
+ "Sin datos de disponibilidad",
+ "No se encontró punto de control",
+ "No hay comandos disponibles",
+ "No hay archivo de configuración que respaldar",
+ "No se encontró archivo PID del demonio; el demonio no está en ejecución",
+ "No se detectó demonio (no existe el archivo PID); creando sesión local. Ruta del PID: %s",
+ "Ningún archivo seleccionado",
+ "No hay archivos que deseleccionar",
+ "No hay archivos que seleccionar",
+ "No se encontró el directorio de configuraciones regionales",
+ "No se proporcionó URI magnet",
+ "No se proporcionó URI magnet para la operación add_magnet.",
+ "No hay métricas disponibles",
+ "No hay datos de calidad de pares",
+ "Ningún par seleccionado",
+ "No hay pares disponibles",
+ "No hay datos por torrent",
+ "Sin piezas",
+ "Sin archivos reproducibles",
+ "No se detectaron archivos multimedia reproducibles para este torrent.",
+ "No hay eventos de seguridad recientes.",
+ "Ninguna sección seleccionada para editar",
+ "No se detectaron eventos significativos.",
+ "No se capturó actividad del enjambre en la ventana seleccionada.",
+ "Sin muestras del enjambre",
+ "No se cargaron datos del torrent. Vuelva al paso 1.",
+ "No se proporcionó ruta de torrent ni magnet",
+ "No se proporcionó ruta ni magnet para la operación add_torrent.",
+ "Aún no hay torrents con actividad DHT.",
+ "Aún no hay torrents. Use 'add' para empezar a descargar.",
+ "Ningún tracker seleccionado",
+ "No se encontraron trackers",
+ "ID de nodo",
+ "Información del nodo",
+ "Información del nodo no disponible.",
+ "Nodos/cola",
+ "Cubetas no vacías",
+ "Nord",
+ "Normal",
+ "No habilitado",
+ "No habilitado en la configuración",
+ "No inicializado",
+ "Nota",
+ "Número de piezas a verificar por integridad (0 = desactivar)",
+ "OK (simulación — la configuración es válida)",
+ "OK (simulación — la configuración fusionada es válida)",
+ "One Dark",
+ "Solo opciones en esta sección de nivel superior (p. ej. red)",
+ "Solo rutas que comiencen con este prefijo",
+ "Abrir archivo",
+ "Abrir carpeta",
+ "Abrir en VLC",
+ "Carpeta abierta: {path}",
+ "Transmisión abierta en reproductor externo mediante {method}.",
+ "Intervalo de optimistic unchoke (s)",
+ "Opción",
+ "Otros pueden unirse con: ccbt tonic sync \"{link}\" --output ",
+ "Directorio de salida",
+ "Directorio de salida",
+ "Directorio de salida (predeterminado: directorio actual)",
+ "Directorio de salida no disponible",
+ "Ruta del archivo de salida",
+ "Formato de salida del catálogo de opciones",
+ "Eficiencia general",
+ "Salud general",
+ "Sobrescribir puerto del servidor IPC",
+ "Intervalo PEX (s)",
+ "Error al actualizar PEX: {error}",
+ "Actualización de PEX solicitada",
+ "PEX: fallido",
+ "El archivo PID contiene un PID no válido: %d; eliminando",
+ "El archivo PID contiene datos no válidos: %r; eliminando",
+ "El archivo PID está vacío; eliminando",
+ "Analizando archivos y construyendo el árbol...",
+ "Analizando archivos y construyendo metadatos híbridos...",
+ "Formato de parche (auto: inferir por extensión o probar JSON y luego TOML)",
+ "El parche debe ser un objeto JSON/TOML en el nivel superior",
+ "Ruta",
+ "La ruta no existe",
+ "La ruta no es un archivo: %s",
+ "Ruta o magnet://...",
+ "Ruta del archivo de configuración",
+ "Error al pausar: {error}",
+ "Pausar torrent",
+ "En pausa",
+ "Pausado {info_hash}…",
+ "Par",
+ "Detalles del par",
+ "Distribución de pares",
+ "Eficiencia de pares",
+ "Calidad del par",
+ "Distribución de calidad de pares",
+ "Selección de pares",
+ "Vetado de pares aún no implementado. Par seleccionado: {ip}:{port}",
+ "Distribución de pares — error: {error}",
+ "Par no encontrado",
+ "Calidad de pares — error: {error}",
+ "Datos de calidad de pares no disponibles en este modo.",
+ "Tiempo de espera del par (s)",
+ "Par {ip}:{port} vetado",
+ "Pares encontrados",
+ "Pares/cola",
+ "Por par",
+ "Pestaña por par: proveedor de datos o ejecutor no disponible",
+ "Por torrent",
+ "Config. por torrent: {hash}...",
+ "Configuración por torrent",
+ "Configuración por torrent: {name}",
+ "Resumen de calidad por torrent",
+ "Pestaña por torrent: proveedor de datos o ejecutor no disponible",
+ "DHT por torrent",
+ "Configuración por torrent: proveedor de datos, ejecutor o torrent no disponible",
+ "Configuración por torrent guardada correctamente",
+ "Porcentaje",
+ "Métricas de rendimiento",
+ "Métricas de rendimiento — error: {error}",
+ "Permiso denegado",
+ "Estrategia de selección de piezas",
+ "Las métricas de selección de piezas aún no están disponibles para este torrent.",
+ "Métricas de selección de piezas no disponibles en este modo.",
+ "Piezas recibidas",
+ "Piezas servidas",
+ "Fijar contenido en IPFS:",
+ "Rechazos de la canalización",
+ "Utilización de la canalización",
+ "Introduzca la ruta del torrent o el enlace magnet",
+ "Corrija errores de análisis antes de guardar",
+ "Corrija errores de validación antes de guardar",
+ "Seleccione primero un torrent",
+ "Deficiente",
+ "Puerto para la interfaz web",
+ "Puerto: {port}, STUN: {stun_count} servidor(es)",
+ "Preferir protocolo v2 cuando esté disponible",
+ "Preferir sobre TCP",
+ "Preferir uTP cuando TCP y uTP estén disponibles",
+ "Preferir v2: {prefer_v2} | Híbrido: {hybrid} | Tiempo de espera: {timeout}s",
+ "Pulse Ctrl+C para detener el demonio",
+ "Pulse Intro para configurar esta sección",
+ "Anterior",
+ "Paso anterior",
+ "Priorizar la primera pieza",
+ "Priorizar la última pieza",
+ "Piezas priorizadas",
+ "Prioridad (0 = normal, 1 = alta, -1 = baja):",
+ "Nivel de prioridad",
+ "Perfil '{name}' no encontrado",
+ "Perfil aplicado en {path}",
+ "Configuración de perfil escrita en {path}",
+ "Perfil: {name}",
+ "Protocolo v2 (BEP 52)",
+ "Protocolos (Ctrl+)",
+ "Proporcione un argumento VALUE o use --value=... para valores con espacios o JSON",
+ "Configuración del proxy",
+ "La clave pública debe tener 32 bytes (64 caracteres hexadecimales)",
+ "PyYAML es necesario para exportar YAML",
+ "PyYAML es necesario para importar YAML",
+ "PyYAML es necesario para parches YAML",
+ "Calidad",
+ "Distribución de calidad",
+ "Consultas",
+ "Consultas recibidas",
+ "Consultas enviadas",
+ "Añadir torrent rápido",
+ "Estadísticas rápidas",
+ "Añadir torrent rápido",
+ "Multiplicador RTT para tiempo de espera de retransmisión",
+ "Rainbow",
+ "Límites de velocidad (KiB/s)",
+ "Configuración de límites de velocidad (global y por torrent)",
+ "Velocidades",
+ "Leer puerto IPC %d del archivo de configuración del demonio (fuente autoritativa)",
+ "Eventos de seguridad recientes ({count})",
+ "Ajustes recomendados",
+ "Valor recomendado",
+ "Reconectar a pares desde el punto de control",
+ "Recuperación y salud de la canalización",
+ "Actualizar",
+ "Actualizar PEX",
+ "Actualizar estado del tracker desde el punto de control",
+ "Rehash: fallido",
+ "Fragmentos restantes: {count}",
+ "Quitar",
+ "Quitar tracker",
+ "Eliminar puntos de control más antiguos que N días",
+ "Error al quitar: {error}",
+ "Quitar tracker aún no implementado. Tracker seleccionado: {url}",
+ "Seguimiento de reputación"
+]
diff --git a/ccbt/i18n/locale_data/es_val_2.json b/ccbt/i18n/locale_data/es_val_2.json
new file mode 100644
index 00000000..a30578b9
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_2.json
@@ -0,0 +1,191 @@
+[
+ "Eficiencia de solicitudes",
+ "Latencia de solicitud",
+ "Éxito de solicitudes",
+ "Profundidad de canalización de solicitudes",
+ "Obligatorio",
+ "Restablecer solo una clave concreta (si no, restablece todas las opciones)",
+ "Recurso",
+ "Utilización de recursos",
+ "Respuestas recibidas",
+ "Reinicio necesario",
+ "¿Reiniciar el demonio ahora?",
+ "Restauración completada",
+ "Restauración fallida",
+ "Restaurando punto de control...",
+ "Error al reanudar: {error}",
+ "Reanudar desde el punto de control si está disponible",
+ "Reanudar desde el punto de control si está disponible:\n\nSi está habilitado, la descarga continuará desde el último punto de control.",
+ "Reanudar desde el punto de control:",
+ "¿Reanudar desde el punto de control?",
+ "Reanudar torrent",
+ "Reanudado {info_hash}…",
+ "Reanudando {name}",
+ "Factor de tiempo de espera de retransmisión",
+ "Tabla de enrutamiento",
+ "Estadísticas de tabla de enrutamiento no disponibles.",
+ "Regla no encontrada: {ip_range}",
+ "Ejecutar comprobaciones adicionales de compatibilidad del sistema tras la validación del modelo",
+ "Ejecutar en primer plano (para depuración)",
+ "Configuración SSL",
+ "Guardar configuración",
+ "Guardar configuración",
+ "Guardar punto de control tras el restablecimiento",
+ "Guardar punto de control inmediatamente tras establecer la opción",
+ "Guardando torrent en {path}...",
+ "Escaneando carpeta y calculando trozos...",
+ "Esquema escrito en {path}",
+ "Scrape",
+ "Recuento de scrape",
+ "Opciones de scrape:\n\nEl scrape consulta estadísticas del tracker (seeders, leechers, descargas completadas).\nEl auto-scrape consultará el tracker automáticamente al añadir el torrent.",
+ "Resultados de scrape",
+ "Scrape: fallido",
+ "Buscar torrents...",
+ "Sección",
+ "La sección '{section}' no es una sección de configuración",
+ "Sección '{section}' no encontrada",
+ "Sección: {section}",
+ "Seguridad",
+ "Eventos de seguridad",
+ "Estado del análisis de seguridad",
+ "Estadísticas de seguridad",
+ "Configuración de seguridad: proveedor de datos o ejecutor no disponible",
+ "Gestor de seguridad no disponible. El análisis requiere modo de sesión local.",
+ "Análisis de seguridad",
+ "Análisis de seguridad completado. No se detectaron problemas.",
+ "Análisis de seguridad completado. {blocked} conexiones bloqueadas, {events} eventos de seguridad detectados.",
+ "El análisis de seguridad no está disponible al estar conectado al demonio.",
+ "Ajustes de seguridad (cifrado, filtrado IP, SSL)",
+ "Siendo semilla",
+ "Semillas",
+ "Seleccionar",
+ "Seleccionar todo",
+ "Seleccionar prioridad de archivo",
+ "Seleccionar archivos para descargar",
+ "Seleccionar idioma",
+ "Seleccionar prioridad",
+ "Seleccionar sección",
+ "Seleccionar tema",
+ "Seleccione un tipo de gráfico",
+ "Seleccione una sección para configurar",
+ "Seleccione una sección para configurar. Intro para editar, Escape para volver.",
+ "Seleccione una subpestaña para ver opciones de configuración",
+ "Seleccione una subpestaña para ver torrents",
+ "Seleccione un torrent y una subpestaña para ver detalles",
+ "Seleccione una pestaña de información del torrent",
+ "Seleccione una pestaña de flujo de trabajo",
+ "Seleccione archivos para descargar y establezca prioridades:\n Espacio: Alternar selección\n P: Cambiar prioridad\n A: Seleccionar todo\n D: Deseleccionar todo",
+ "Seleccionar archivos: [a]todos, [n]inguno o índices (p. ej. 0,2-5)",
+ "Seleccionar carpeta",
+ "Seleccionar archivo reproducible",
+ "Seleccione la prioridad en cola para este torrent:\n\nLos torrents de mayor prioridad se iniciarán primero.",
+ "Seleccionar torrent...",
+ "Seleccionado(s) {count} archivo(s)",
+ "Establecer límites",
+ "Establecer prioridad",
+ "Establecer configuración regional (p. ej. 'en', 'es', 'fr')",
+ "Establecer prioridad {priority} para el archivo",
+ "Establecer límites de velocidad para este torrent:\n\nIntroduzca 0 o déjelo vacío para ilimitado.",
+ "Ajuste",
+ "Ratio de compartición",
+ "Error al compartir",
+ "Pares compartidos",
+ "Mostrar puntos de control en un formato concreto",
+ "Mostrar qué se eliminaría sin eliminarlo realmente",
+ "Tiempo de espera de apagado en segundos",
+ "Tamaño: {size}",
+ "Omitir y continuar",
+ "Omitir espera y seleccionar todos los archivos",
+ "Optimizaciones de socket",
+ "Prueba de conexión de socket a %s:%d fallida (resultado=%d). El puerto puede estar cerrado o un firewall lo bloquea. Se continuará con la comprobación HTTP de todas formas.",
+ "Gestor de sockets no inicializado",
+ "Búfer de recepción del socket (KiB)",
+ "Búfer de envío del socket (KiB)",
+ "La prueba de socket devolvió 10035 (WSAEWOULDBLOCK) en Windows para %s:%d. Puede ser un falso positivo; se continúa con la comprobación HTTP.",
+ "Solarized oscuro",
+ "Solarized claro",
+ "La ruta de origen no existe: %s",
+ "Categoría de velocidad",
+ "Velocidades",
+ "Iniciar transmisión",
+ "Inicie una transmisión para exponer una URL HTTP en localhost para VLC u otro reproductor externo. El vídeo integrado en terminal no está contemplado.",
+ "Iniciar demonio en segundo plano sin esperar a que termine (inicio más rápido)",
+ "Modo interactivo",
+ "Inicie la transmisión antes de abrir VLC.",
+ "Iniciando demonio...",
+ "Iniciando verificación de archivos...",
+ "Estado: detenido\nÍndice de archivo seleccionado: {index}",
+ "Estado: {state}\nURL: {url}\nPreparación del búfer: {buffer:.0%}",
+ "Paso {current}/{total}: {steps}",
+ "Detener transmisión",
+ "Detenido",
+ "Deteniendo demonio para reiniciar...",
+ "Deteniendo demonio...",
+ "Deteniendo demonio... ({elapsed:.1f}s)",
+ "Almacenamiento",
+ "Detección de dispositivo de almacenamiento",
+ "Tipo de almacenamiento",
+ "Configuración de almacenamiento: proveedor de datos o ejecutor no disponible",
+ "Estrategia",
+ "Piezas atascadas recuperadas",
+ "Enviar",
+ "Correcto",
+ "Solicitudes correctas",
+ "Resumen",
+ "Los destinos de reproducción MVP admitidos incluyen archivos de audio/vídeo habituales.",
+ "Salud del enjambre",
+ "Línea temporal del enjambre",
+ "Salud del enjambre — error: {error}",
+ "Línea temporal del enjambre — error: {error}",
+ "Eficiencia del sistema",
+ "Recomendaciones del sistema:",
+ "Recursos del sistema",
+ "Recursos del sistema — error: {error}",
+ "Plantilla '{name}' no encontrada",
+ "Plantilla aplicada en {path}",
+ "Configuración de plantilla escrita en {path}",
+ "Plantilla: {name}",
+ "Plantillas: {templates}",
+ "Textual oscuro",
+ "Tema",
+ "Tema: {theme}",
+ "Este torrent no tiene archivos para seleccionar.",
+ "Esto modificará su archivo de configuración. ¿Continuar?",
+ "Nivel",
+ "Tiempo",
+ "Línea temporal",
+ "Datos de línea temporal no disponibles en este modo.",
+ "Tiempo de espera al comprobar accesibilidad del demonio (intento %d/%d, transcurrido %.1fs), reintentando en %.1fs...",
+ "Tiempo de espera al comprobar accesibilidad del demonio tras %d intentos (transcurrido %.1fs)",
+ "Tiempo de espera al comprobar el estado del demonio en %s (el demonio puede estar iniciándose o sobrecargado)",
+ "Sugerencia: catálogo completo de opciones y fusión de archivos → ",
+ "Alternar oscuro/claro",
+ "Tokyo Night",
+ "Los 10 mejores pares por calidad",
+ "Entradas principales del perfil:",
+ "Torrent",
+ "Control de torrent",
+ "Controles de torrent",
+ "Controles de torrent: proveedor de datos o ejecutor no disponible",
+ "Controles de torrent — error: {error}",
+ "Explorador de archivos de torrent",
+ "Información del torrent",
+ "Configuración del torrent",
+ "El archivo torrent está vacío: %s",
+ "Archivo torrent no encontrado: %s",
+ "Torrent en pausa",
+ "Prioridad del torrent",
+ "Torrent eliminado",
+ "Torrent reanudado",
+ "Torrent guardado en {path}",
+ "Pestaña Torrents: proveedor de datos o ejecutor no disponible",
+ "Torrents con DHT",
+ "Cubetas totales",
+ "Conexiones totales",
+ "Descargado total",
+ "Nodos totales",
+ "Pares totales",
+ "Pares totales: {total} | Pares activos: {active}",
+ "Consultas totales",
+ "Solicitudes totales"
+]
diff --git a/ccbt/i18n/locale_data/es_val_3.json b/ccbt/i18n/locale_data/es_val_3.json
new file mode 100644
index 00000000..41c5c909
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_3.json
@@ -0,0 +1,191 @@
+[
+ "Tamaño total",
+ "Subida total",
+ "Trozos totales: {count}",
+ "Consultas totales",
+ "Tracker",
+ "Error de tracker",
+ "Tracker añadido: {url}",
+ "Intervalo de announce del tracker (s)",
+ "Tracker quitado: {url}",
+ "Intervalo de scrape del tracker (s)",
+ "Trackers",
+ "Siguiendo {count} torrent(s) en una ventana de {minutes} minuto(s)",
+ "Tendencia: {trend} ({delta:+.1f}pp)",
+ "Intervalo de actualización de la UI: {interval}s",
+ "URL",
+ "No disponible",
+ "Intervalo de unchoke (s)",
+ "Error inesperado al comprobar el estado del demonio en %s: %s",
+ "Error desconocido",
+ "Operación desconocida «{operation}» solicitada pero existe archivo PID del demonio. No debería ocurrir; repórtelo como error.",
+ "Operación desconocida: %s",
+ "Ilimitado",
+ "Subida (B/s)",
+ "Actualizado a las {time}",
+ "Archivo de configuración actualizado con la del demonio",
+ "Límite de subida",
+ "Límite de subida (KiB/s):",
+ "Tasa de subida",
+ "Límite de tasa de subida (bytes/s, 0 = ilimitado):",
+ "Límite de subida (KiB/s, 0 = ilimitado)",
+ "Subida:",
+ "Subido",
+ "Subiendo",
+ "Tiempo en marcha",
+ "Uso",
+ "Uso: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema",
+ "Uso: disk [show|stats|config |monitor]",
+ "Uso: network [show|stats|config |optimize|monitor]",
+ "Use «btbt daemon restart» o reinicie el demonio manualmente.",
+ "Use --confirm para continuar con la restauración",
+ "Use --force para forzar la terminación",
+ "Usar solo protocolo v2 (desactivar v1)",
+ "Usar asignación en memoria (mmap)",
+ "Usando puerto IPC %d de la configuración principal",
+ "Usando archivo de configuración del demonio: puerto=%d, api_key_present=%s",
+ "Usando ejecutor del demonio para el comando magnet",
+ "Usando puerto IPC predeterminado %d (puede no existir el archivo de config. del demonio)",
+ "Mediana de utilización",
+ "Rango de utilización",
+ "Muestras de utilización",
+ "Generación de torrent v1 aún no implementada",
+ "VS Code oscuro",
+ "Validar solo la superposición del archivo fusionado; no escribir",
+ "Solo validar; no escribir el archivo de configuración",
+ "Error de validación: %s",
+ "Valor a establecer (útil para cadenas con espacios o JSON); sobrescribe VALUE posicional",
+ "Verificación completada: {verified} correctos, {failed} fallidos de {total}",
+ "Verificación fallida: {error}",
+ "Verificar archivos",
+ "Visual",
+ "Esperar metadatos",
+ "Esperar metadatos y solicitar selección de archivos (solo interactivo)",
+ "Advertencias:",
+ "Error WebSocket en recepción por lotes: %s",
+ "Error WebSocket: %s",
+ "Error en bucle de recepción WebSocket: %s",
+ "WebTorrent",
+ "Tamaño de lista blanca",
+ "Pares en lista blanca",
+ "Error específico de Windows al comprobar el demonio (os.kill()): %s — no hay archivo PID; se creará sesión local",
+ "Tiempo de espera de lote de escritura",
+ "Tamaño de lote de escritura (KiB)",
+ "Tamaño de búfer de escritura (KiB)",
+ "Escribir configuración fusionada en el archivo global",
+ "Escribir configuración fusionada en ccbt.toml local del proyecto",
+ "Caché de write-back",
+ "Escribiendo archivo de exportación...",
+ "Catálogo escrito en {path}",
+ "Carpetas XET",
+ "Opciones del protocolo Xet:\n\nXet permite trozos definidos por contenido y deduplicación.\nÚtil para reducir almacenamiento al descargar contenido similar.",
+ "Gestión Xet",
+ "Puede omitir la espera y continuar con todos los archivos seleccionados.",
+ "Recuento de estado cero",
+ "[blue]Progreso: {verified}/{total} piezas verificadas[/blue]",
+ "[blue]Ejecutando: {command}[/blue]",
+ "[bold green]Enlace para compartir:[/bold green]",
+ "[bold]Alias ({count}):[/bold]\n",
+ "[bold]Lista permitida ({count} pares):[/bold]\n",
+ "[bold]Configuración:[/bold]",
+ "[bold]Descubriendo dispositivos NAT...[/bold]\n",
+ "[bold]Asignando puerto {protocol} {port}...[/bold]",
+ "[bold]Estado de NAT traversal[/bold]\n",
+ "[bold]Quitando asignación de puerto {protocol} para puerto {port}...[/bold]",
+ "[bold]Modo de sincronización para: {path}[/bold]\n",
+ "[bold]Estado de sincronización para: {path}[/bold]\n",
+ "[bold]Información de caché Xet[/bold]\n",
+ "[bold]Estadísticas de caché de deduplicación Xet[/bold]\n",
+ "[bold]Estado del protocolo Xet[/bold]\n",
+ "[cyan]Comprobando si ya hay una instancia del demonio...[/cyan]",
+ "[cyan]Creando torrent {format}...[/cyan]",
+ "[cyan]Descarga:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Inicializando configuración...[/cyan]",
+ "[cyan]Cargando filtro desde: {file_path}[/cyan]",
+ "[cyan]Reiniciando demonio...[/cyan]",
+ "[cyan]Ejecutando comprobaciones de diagnóstico...[/cyan]\n",
+ "[cyan]Iniciando demonio en segundo plano...[/cyan]",
+ "[cyan]Iniciando demonio en primer plano...[/cyan]",
+ "[cyan]Probando conexión al proxy {host}:{port}...[/cyan]",
+ "[cyan]Torrents:[/cyan] {num_torrents}",
+ "[cyan]Actualizando listas de filtro desde {count} URL(s)...[/cyan]",
+ "[cyan]Subida:[/cyan] {rate:.2f} KiB/s",
+ "[cyan]Tiempo en marcha:[/cyan] {uptime:.1f}s",
+ "[cyan]Usando puerto IPC personalizado: {port}[/cyan]",
+ "[cyan]Esperando a que el demonio esté listo...[/cyan]",
+ "[dim] uv run btbt daemon start --foreground[/dim]",
+ "[dim]El demonio puede seguir iniciándose. Use «btbt daemon status» para comprobar.[/dim]",
+ "[dim]Info hash v1 (SHA-1): {hash}...[/dim]",
+ "[dim]Info hash v2 (SHA-256): {hash}...[/dim]",
+ "[dim]Sin asignaciones de puerto activas[/dim]",
+ "[dim]Salida: {path}[/dim]",
+ "[dim]Reinicie manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Reinicie el demonio manualmente: «btbt daemon restart»[/dim]",
+ "[dim]Protocolo: {method}[/dim]",
+ "[dim]Vea el registro del demonio: {path}[/dim]",
+ "[dim]Origen: {path}[/dim]",
+ "[dim]Trackers: {count}[/dim]",
+ "[dim]Intente con la opción --foreground para ver el error detallado:[/dim]",
+ "[dim]Use «btbt daemon status» para el estado del demonio[/dim]",
+ "[dim]Use -v para más detalles o revise los registros del demonio[/dim]",
+ "[dim]Web seeds: {count}[/dim]",
+ "[green]PERMITIDO[/green]",
+ "[green]Protocolo activo:[/green] {method}",
+ "[green]Regla de alerta {name} añadida[/green]",
+ "[green]Añadido a IPFS:[/green] {cid}",
+ "[green]Aplicando optimizaciones {preset}...[/green]",
+ "[green]Resultados de benchmark:[/green] {results}",
+ "[green]Ruta de certificados CA establecida en {path}. Configuración guardada en {config_file}[/green]",
+ "[green]Punto de control para {hash} es válido[/green]",
+ "[green]Punto de control para {info_hash} es válido[/green]",
+ "[green]Punto de control actualizado para {hash}[/green]",
+ "[green]Punto de control recargado para {hash}[/green]",
+ "[green]Punto de control guardado para el torrent[/green]",
+ "[green]Punto de control guardado[/green]",
+ "[green]Punto de control válido[/green]",
+ "[green]Se borraron todas las alertas activas[/green]",
+ "[green]Cola vaciada[/green]",
+ "[green]Certificado de cliente establecido. Configuración guardada en {config_file}[/green]",
+ "[green]Conectado al demonio[/green]",
+ "[green]Contenido fijado[/green]",
+ "[green]Contenido guardado en:[/green] {output}",
+ "[green]Modo DHT agresivo {mode} para torrent: {info_hash}[/green]",
+ "[green]El demonio está en ejecución[/green] (PID: {pid})",
+ "[green]Demonio reiniciado correctamente[/green]",
+ "[green]Demonio detenido correctamente[/green]",
+ "[green]Demonio detenido[/green]",
+ "[green]Punto de control eliminado para {hash}[/green]",
+ "[green]Punto de control eliminado para {info_hash}[/green]",
+ "[green]Todos los archivos deseleccionados.[/green]",
+ "[green]Todos los archivos deseleccionados[/green]",
+ "[green]Deseleccionado(s) {count} archivo(s)[/green]",
+ "[green]IP externa:[/green] {ip}",
+ "[green]Forzado el inicio de {count} torrent(s)[/green]",
+ "[green]Punto de control encontrado para: {torrent_name}[/green]",
+ "[green]Verificación de integridad correcta: {count} piezas verificadas[/green]",
+ "[green]Reglas de alerta cargadas desde {path}[/green]",
+ "[green]Cargadas {count} reglas de alerta desde {path}[/green]",
+ "[green]Configuración regional establecida en: {locale_code}[/green]",
+ "[green]Enlace magnet añadido al demonio: {info_hash}[/green]",
+ "[green]Movido a la posición {position}[/green]",
+ "[green]¡La configuración de red parece óptima![/green]",
+ "[green]No hay puntos de control con más de {days} días[/green]",
+ "[green]¡Optimizaciones aplicadas correctamente![/green]\n[yellow]Nota: algunos cambios pueden requerir reinicio.[/yellow]",
+ "[green]Optimizaciones guardadas en {path}[/green]",
+ "[green]PEX actualizado para torrent: {info_hash}[/green]",
+ "[green]Torrent pausado[/green]",
+ "[green]Pausado(s) {count} torrent(s)[/green]",
+ "[green]Los hooks de validación de pares están habilitados por configuración[/green]",
+ "[green]Límite de velocidad por par para {peer_key}: {limit}[/green]",
+ "[green]Límite por par establecido: {peer_key} = {upload} KiB/s[/green]",
+ "[green]Realizando análisis básico de configuración...[/green]",
+ "[green]Fijado:[/green] {cid}",
+ "[green]Configuración del proxy guardada en {config_file}[/green]",
+ "[green]Configuración del proxy actualizada correctamente[/green]",
+ "[green]El proxy se ha desactivado[/green]",
+ "[green]Regla de alerta {name} eliminada[/green]",
+ "[green]Torrent quitado de la cola[/green]",
+ "[green]Todas las opciones restablecidas para torrent {hash}[/green]",
+ "[green]Restablecido {key} para torrent {hash}[/green]",
+ "[green]Punto de control restaurado para: {name}[/green]\nHash de información: {hash}"
+]
diff --git a/ccbt/i18n/locale_data/es_val_4.json b/ccbt/i18n/locale_data/es_val_4.json
new file mode 100644
index 00000000..5b896e82
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_4.json
@@ -0,0 +1,191 @@
+[
+ "[green]La estructura de datos de reanudación es válida[/green]",
+ "[green]Torrent reanudado[/green]",
+ "[green]Reanudado(s) {count} torrent(s)[/green]",
+ "[green]Reanudando desde el punto de control[/green]",
+ "[green]Verificación de certificado SSL habilitada. Configuración guardada en {config_file}[/green]",
+ "[green]SSL para pares desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL para pares habilitado (experimental). Configuración guardada en {config_file}[/green]",
+ "[green]SSL para trackers desactivado. Configuración guardada en {config_file}[/green]",
+ "[green]SSL para trackers habilitado. Configuración guardada en {config_file}[/green]",
+ "[green]Reglas de alerta guardadas en {path}[/green]",
+ "[green]Datos de reanudación guardados para {hash}[/green]",
+ "[green]Todos los archivos seleccionados[/green]",
+ "[green]Seleccionado(s) {count} archivo(s).[/green]",
+ "[green]Seleccionado(s) {count} archivo(s)[/green]",
+ "[green]Prioridad del archivo {index} establecida en {priority}[/green]",
+ "[green]Prioridad establecida en {priority}[/green]",
+ "[green]Límite de velocidad para {count} pares: {upload} KiB/s[/green]",
+ "[green]Establecido {key} = {value} para torrent {hash}[/green]",
+ "[green]Descarga reanudada correctamente: {hash}[/green]",
+ "[green]Descarga reanudada correctamente: {resumed_info_hash}[/green]",
+ "[green]Versión de protocolo TLS establecida en {version}. Configuración guardada en {config_file}[/green]",
+ "[green]Regla {name} probada con valor {value}[/green]",
+ "[green]Torrent añadido al demonio: {info_hash}[/green]",
+ "[green]Torrent cancelado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent forzado a iniciar: {info_hash}[/green]",
+ "[green]Torrent pausado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Torrent reanudado: {info_hash}{checkpoint_info}[/green]",
+ "[green]Tracker {url} añadido al torrent {info_hash}[/green]",
+ "[green]Tracker {url} quitado del torrent {info_hash}[/green]",
+ "[green]Desfijado:[/green] {cid}",
+ "[green]Actualizado {key} a {value}[/green]",
+ "[green]Métricas escritas en {path}[/green]",
+ "[green]{message}: {config_file}[/green]",
+ "[green]✓ Asignación de puerto eliminada[/green]",
+ "[green]✓ ¡Asignación de puerto correcta![/green]",
+ "[green]✓ Asignaciones de puerto actualizadas[/green]",
+ "[green]✓ Prueba de conexión al proxy correcta[/green]",
+ "[green]✓ Torrent creado correctamente: {path}[/green]",
+ "[green]✓[/green] Regla de filtro añadida: {ip_range} ({mode})",
+ "[green]✓[/green] Par {peer_id} añadido a la lista permitida",
+ "[green]✓[/green] Par {peer_id} añadido a la lista permitida con alias '{alias}'",
+ "[green]✓[/green] Limpiados {cleaned} trozos no usados",
+ "[green]✓[/green] Configuración guardada en {file}",
+ "[green]✓[/green] Proceso del demonio iniciado (PID {pid})",
+ "[green]✓[/green] Demonio iniciado correctamente (PID {pid}, tardó {elapsed:.1f}s)",
+ "[green]✓[/green] Sincronización de carpeta iniciada",
+ "[green]✓[/green] Archivo .tonic generado: {file}",
+ "[green]✓[/green] Nueva clave de API generada para el demonio",
+ "[green]✓[/green] Generated tonic?: link:",
+ "[green]✓[/green] Cargadas {loaded} reglas desde {file_path}",
+ "[green]✓[/green] Cargadas {total_loaded} reglas en total",
+ "[green]✓[/green] Alias eliminado para el par {peer_id}",
+ "[green]✓[/green] Regla de filtro eliminada: {ip_range}",
+ "[green]✓[/green] Par {peer_id} quitado de la lista permitida",
+ "[green]✓[/green] Alias '{alias}' establecido para el par {peer_id}",
+ "[green]✓[/green] Establecido {key} = {value}",
+ "[green]✓[/green] Actualizadas correctamente {count} lista(s) de filtro",
+ "[green]✓[/green] Modo de sincronización actualizado",
+ "[green]✓[/green] Enlace tonic:",
+ "[green]✓[/green] Archivo de configuración actualizado: {file}",
+ "[green]✓[/green] Protocolo Xet habilitado",
+ "[green]✓[/green] Configuración uTP restablecida a valores predeterminados",
+ "[green]✓[/green] Transporte uTP habilitado",
+ "[red]Se requiere --name para quitar una regla[/red]",
+ "[red]Se requiere --name para probar una regla[/red]",
+ "[red]Se requieren --name, --metric y --condition para añadir una regla[/red]",
+ "[red]Se requiere --value con --test[/red]",
+ "[red]BLOQUEADO[/red]",
+ "[red]El archivo de certificado no existe: {path}[/red]",
+ "[red]La ruta del certificado debe ser un archivo: {path}[/red]",
+ "[red]Clave de configuración no encontrada: {key}[/red]",
+ "[red]Contenido no encontrado: {cid}[/red]",
+ "[red]El demonio no está en ejecución[/red]",
+ "[red]El proceso del demonio falló[/red]",
+ "[red]Error del panel: {e}[/red]",
+ "[red]Los directorios aún no están soportados[/red]",
+ "[red]Error al añadir contenido: {e}[/red]",
+ "[red]Error al añadir par a la lista permitida: {e}[/red]",
+ "[red]Error al desactivar SSL para pares: {e}[/red]",
+ "[red]Error al desactivar SSL para trackers: {e}[/red]",
+ "[red]Error al desactivar el protocolo Xet: {e}[/red]",
+ "[red]Error al desactivar la verificación de certificados: {e}[/red]",
+ "[red]Error durante la limpieza: {e}[/red]",
+ "[red]Error al activar SSL para pares: {e}[/red]",
+ "[red]Error al activar SSL para trackers: {e}[/red]",
+ "[red]Error al activar el protocolo Xet: {e}[/red]",
+ "[red]Error al activar la verificación de certificados: {e}[/red]",
+ "[red]Error al asegurar que el demonio está en ejecución: {e}[/red]",
+ "[red]Error al generar el archivo .tonic: {e}[/red]",
+ "[red]Error al generar el enlace tonic: {e}[/red]",
+ "[red]Error al obtener el estado SSL: {e}[/red]",
+ "[red]Error al obtener el estado Xet: {e}[/red]",
+ "[red]Error al obtener el contenido: {e}[/red]",
+ "[red]Error al obtener los pares: {e}[/red]",
+ "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error al obtener el estado: {e}[/red]",
+ "[red]Error al obtener el modo de sincronización: {e}[/red]",
+ "[red]Error al listar alias: {e}[/red]",
+ "[red]Error al listar la lista permitida: {e}[/red]",
+ "[red]Error al fijar contenido: {e}[/red]",
+ "[red]Error al leer el estado del enjambre autenticado: {e}[/red]",
+ "[red]Error al eliminar alias: {e}[/red]",
+ "[red]Error al quitar par de la lista permitida: {e}[/red]",
+ "[red]Error al reiniciar el demonio: {e}[/red]",
+ "[red]Error al obtener información de caché: {e}[/red]",
+ "[red]Error al obtener estadísticas de disco: {error}[/red]",
+ "[red]Error al obtener estadísticas de red: {error}[/red]",
+ "[red]Error al obtener estadísticas: {e}[/red]",
+ "[red]Error al establecer la ruta de certificados CA: {e}[/red]",
+ "[red]Error al establecer alias: {e}[/red]",
+ "[red]Error al establecer certificado de cliente: {e}[/red]",
+ "[red]Error al establecer la versión de protocolo: {e}[/red]",
+ "[red]Error al establecer el modo de sincronización: {e}[/red]",
+ "[red]Error al iniciar la sincronización: {e}[/red]",
+ "[red]Error al desfijar contenido: {e}[/red]",
+ "[red]Error al actualizar el modo de enjambre autenticado: {e}[/red]",
+ "[red]Error al actualizar la configuración: {error}[/red]",
+ "[red]Error al actualizar el modo de descubrimiento: {e}[/red]",
+ "[red]Error al actualizar el comportamiento de parse-policy: {e}[/red]",
+ "[red]Error al actualizar el modo de descubrimiento estricto: {e}[/red]",
+ "[red]Error al actualizar los ID de confianza: {e}[/red]",
+ "[red]Error: no puede especificar --hybrid y --v1 a la vez[/red]",
+ "[red]Error: no puede especificar --v2 y --hybrid a la vez[/red]",
+ "[red]Error: no puede especificar --v2 y --v1 a la vez[/red]",
+ "[red]Error: configuración no disponible[/red]",
+ "[red]Error: no se pudo obtener el estado del demonio: {error}[/red]",
+ "[red]Error: el info hash debe tener 40 caracteres hexadecimales[/red]",
+ "[red]Error: archivo torrent no válido: {torrent_file}[/red]",
+ "[red]Error: configuración de red no disponible[/red]",
+ "[red]Error: la longitud de pieza debe ser potencia de 2[/red]",
+ "[red]Error: la longitud de pieza debe ser al menos 16 KiB (16384 bytes)[/red]",
+ "[red]Error: el directorio de origen está vacío[/red]",
+ "[red]Error: la ruta de origen no existe: {path}[/red]",
+ "[red]Error: {e}[/red]",
+ "[red]Error:[/red] Valor no válido para {key}: {value}",
+ "[red]Error:[/red] Clave de configuración desconocida: {key}",
+ "[red]Exportación no disponible en modo demonio[/red]",
+ "[red]No se pudo añadir el magnet: {error}[/red]",
+ "[red]No se pudo cancelar: {error}[/red]",
+ "[red]No se pudieron borrar las alertas activas: {e}[/red]",
+ "[red]No se pudo crear la sesión[/red]",
+ "[red]No se pudo desactivar el proxy: {e}[/red]",
+ "[red]No se pudo forzar el inicio: {error}[/red]",
+ "[red]No se pudo obtener el estado del proxy: {e}[/red]",
+ "[red]No se pudieron cargar las reglas de alerta: {e}[/red]",
+ "[red]No se pudieron cargar las reglas: {e}[/red]",
+ "[red]No se pudo pausar: {error}[/red]",
+ "[red]No se pudieron restablecer las opciones[/red]",
+ "[red]No se pudo reiniciar el demonio[/red]",
+ "[red]No se pudo reanudar: {error}[/red]",
+ "[red]No se pudieron ejecutar las pruebas: {e}[/red]",
+ "[red]No se pudieron guardar las reglas: {e}[/red]",
+ "[red]No se pudo establecer la opción[/red]",
+ "[red]No se pudo establecer la configuración del proxy: {e}[/red]",
+ "[red]No se pudo iniciar el demonio. No se puede continuar sin demonio.[/red]\n[yellow]Compruebe:[/yellow]\n 1. Registros del demonio por errores de inicio\n 2. Conflictos de puerto (¿el puerto está en uso?)\n 3. Permisos (¿puede iniciar el demonio?)\n\n[cyan]Para iniciar manualmente: «btbt daemon start»[/cyan]",
+ "[red]No se pudo detener: {error}[/red]",
+ "[red]No se pudo probar el proxy: {e}[/red]",
+ "[red]No se pudo probar la regla: {e}[/red]",
+ "[red]Fallo: {error}[/red]",
+ "[red]Archivo no encontrado: {e}[/red]",
+ "[red]Filtro IP no inicializado. Habilítelo en la configuración.[/red]",
+ "[red]Filtro IP no inicializado.[/red]",
+ "[red]Protocolo IPFS no disponible[/red]",
+ "[red]Importación no disponible en modo demonio[/red]",
+ "[red]Dirección IP no válida: {ip}[/red]",
+ "[red]Formato de info hash no válido[/red]",
+ "[red]Info hash no válido: {hash}[/red]",
+ "[red]Enlace magnet no válido: {e}[/red]",
+ "[red]Clave pública no válida: {e}[/red]",
+ "[red]Valor no válido para {key}: {error}[/red]",
+ "[red]El archivo de clave no existe: {path}[/red]",
+ "[red]La ruta de la clave debe ser un archivo: {path}[/red]",
+ "[red]Error de métricas: {e}[/red]",
+ "[red]No hay estadísticas para el CID: {cid}[/red]",
+ "[red]La ruta no existe: {path}[/red]",
+ "[red]La ruta debe ser un archivo o directorio: {path}[/red]",
+ "[red]Par {peer_id} no encontrado en la lista permitida[/red]",
+ "[red]Error del proxy: {e}[/red]",
+ "[red]Deben configurarse host y puerto del proxy[/red]",
+ "[red]Regla no encontrada: {name}[/red]",
+ "[red]Especifique CID o use --all[/red]",
+ "[red]Torrent no encontrado: {hash}[/red]",
+ "[red]Error inesperado al reanudar: {e}[/red]",
+ "[red]Clave de configuración desconocida: {key}[/red]",
+ "[red]Error de validación: {e}[/red]",
+ "[red]{msg}[/red]",
+ "[red]✗ No se pudo quitar la asignación de puerto[/red]",
+ "[red]✗ Falló la asignación de puerto[/red]",
+ "[red]✗ Falló la prueba de conexión al proxy[/red]"
+]
diff --git a/ccbt/i18n/locale_data/es_val_5.json b/ccbt/i18n/locale_data/es_val_5.json
new file mode 100644
index 00000000..07b90342
--- /dev/null
+++ b/ccbt/i18n/locale_data/es_val_5.json
@@ -0,0 +1,190 @@
+[
+ "[red]✗[/red] El demonio ya está en ejecución con PID {pid}",
+ "[red]✗[/red] El proceso del demonio (PID {pid}) falló durante el inicio (tras {elapsed:.1f}s)",
+ "[red]✗[/red] El proceso del demonio (PID {pid}) salió inmediatamente tras iniciar",
+ "[red]✗[/red] No se pudo añadir la regla de filtro: {ip_range}",
+ "[red]✗[/red] No se pudieron cargar reglas desde {file_path}",
+ "[red]✗[/red] No se pudo iniciar el demonio: {e}",
+ "[red]✗[/red] No se pudieron actualizar las listas de filtro",
+ "[yellow]1. Conectividad de red[/yellow]",
+ "[yellow]No se encontró clave de API en la configuración; no se puede obtener estado detallado[/yellow]",
+ "[yellow]Protocolo activo:[/yellow] Ninguno (no descubierto)",
+ "[yellow]La lista permitida está vacía[/yellow]",
+ "[yellow]Ajuste de enjambre autenticado actualizado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Ajuste de enjambre autenticado actualizado (modo prueba, escritura omitida)[/yellow]",
+ "[yellow]Enjambres autenticados no configurados[/yellow]",
+ "[yellow]Reparación automática no implementada[/yellow]",
+ "[yellow]Ruta de certificados CA en {path} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Ruta de certificados CA en {path} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]El punto de control no puede reanudarse solo: no se encontró fuente del torrent[/yellow]",
+ "[yellow]El punto de control para {hash} falta o no es válido[/yellow]",
+ "[yellow]Punto de control ausente o no válido[/yellow]",
+ "[yellow]Certificado de cliente establecido (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Certificado de cliente establecido (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Los cambios de configuración requieren reiniciar el demonio.[/yellow]",
+ "[yellow]No se pudo deseleccionar: {error}[/yellow]",
+ "[yellow]No se pudo obtener estado detallado por IPC[/yellow]",
+ "[yellow]No se pudo guardar en el archivo de configuración: {error}[/yellow]",
+ "[yellow]El gestor de E/S de disco no está en ejecución. Estadísticas no disponibles.[/yellow]",
+ "[yellow]Simulación: se limpiarían trozos de más de {days} días[/yellow]",
+ "[yellow]IP externa no disponible[/yellow]",
+ "[yellow]IP externa:[/yellow] No disponible",
+ "[yellow]No se pudo generar el enlace tonic[/yellow]",
+ "[yellow]No se pudo mover el torrent[/yellow]",
+ "[yellow]No se pudo actualizar el punto de control para {hash}[/yellow]",
+ "[yellow]No se pudo recargar el punto de control para {hash}[/yellow]",
+ "[yellow]Reanudación rápida desactivada[/yellow]",
+ "[yellow]Punto de control encontrado para: {name}[/yellow]",
+ "[yellow]Punto de control encontrado para: {torrent_name}[/yellow]",
+ "[yellow]Rehash completo no implementado en CLI; use reanudar para verificar piezas[/yellow]",
+ "[yellow]Filtro IP no inicializado o desactivado.[/yellow]",
+ "[yellow]Falló la verificación de integridad: {count} piezas erróneas[/yellow]",
+ "[yellow]Estado NAT[/yellow]",
+ "[yellow]Optimizador de red no disponible[/yellow]",
+ "[yellow]Estadísticas de red no disponibles[/yellow]",
+ "[yellow]No hay alertas activas[/yellow]",
+ "[yellow]No hay reglas de alerta definidas[/yellow]",
+ "[yellow]No hay alias para el par {peer_id}[/yellow]",
+ "[yellow]No hay alias en la lista permitida[/yellow]",
+ "[yellow]No hay configuración de enjambres autenticados[/yellow]",
+ "[yellow]No hay resultados de scrape en caché[/yellow]",
+ "[yellow]No hay punto de control para {hash}[/yellow]",
+ "[yellow]No hay punto de control para {info_hash}[/yellow]",
+ "[yellow]No hay trozos en caché[/yellow]",
+ "[yellow]No se encontró archivo de configuración — no se persistió[/yellow]",
+ "[yellow]No hay lista de archivos en {timeout}s; se continúa con selección predeterminada.[/yellow]",
+ "[yellow]No hay URL de filtro configuradas.[/yellow]",
+ "[yellow]No hay reglas de filtro configuradas.[/yellow]",
+ "[yellow]No se aplicaron optimizaciones (ya óptimo o no soportado)[/yellow]",
+ "[yellow]No se especificó acción de rendimiento[/yellow]",
+ "[yellow]No se especificó acción de recuperación[/yellow]",
+ "[yellow]No hay datos de reanudación en el punto de control[/yellow]",
+ "[yellow]No se especificó acción de seguridad[/yellow]",
+ "[yellow]No hay configuración de seguridad cargada[/yellow]",
+ "[yellow]Índices no válidos; se mantiene la selección predeterminada.[/yellow]",
+ "[yellow]Modo no interactivo; iniciando descarga nueva[/yellow]",
+ "[yellow]Nota: este cambio es temporal y se perderá al reiniciar. Use archivo de config. para persistir.[/yellow]",
+ "[yellow]Nota: actualice el archivo de configuración para persistir la configuración regional[/yellow]",
+ "[yellow]Nota:[/yellow] El cambio de configuración solo aplica en tiempo de ejecución",
+ "[yellow]Optimización cancelada[/yellow]",
+ "[yellow]Par {peer_id} no encontrado en la lista permitida[/yellow]",
+ "[yellow]Proporcione el archivo torrent original o el enlace magnet[/yellow]",
+ "[yellow]Por ahora use las opciones --v2 o --hybrid.[/yellow]",
+ "[yellow]Configuración del proxy no encontrada[/yellow]",
+ "[yellow]Configuración del proxy actualizada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]El proxy se desactivó (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]El proxy no está habilitado[/yellow]",
+ "[yellow]Monitorización en tiempo real aún no implementada[/yellow]",
+ "[yellow]Actualización completada con advertencias[/yellow]",
+ "[yellow]La validación de datos de reanudación encontró problemas:[/yellow]",
+ "[yellow]Rich no disponible; iniciando descarga nueva[/yellow]",
+ "[yellow]Regla no encontrada: {ip_range}[/yellow]",
+ "[yellow]Verificación de certificado SSL desactivada (no recomendado). Configuración guardada en {config_file}[/yellow]",
+ "[yellow]Verificación SSL desactivada (no recomendado, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Verificación SSL desactivada (no recomendado, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Verificación SSL habilitada (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Verificación SSL habilitada (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL para pares desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL para pares desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL para pares habilitado (experimental, configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL para pares habilitado (experimental, escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL para trackers desactivado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL para trackers desactivado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]SSL para trackers habilitado (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]SSL para trackers habilitado (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]Error al seleccionar: {error}[/yellow]",
+ "[yellow]Use --download-limit/--upload-limit para límites globales; por par vía configuración[/yellow]",
+ "[yellow]Iniciando descarga nueva[/yellow]",
+ "[yellow]Versión TLS en {version} (configuración no persistida — sin archivo)[/yellow]",
+ "[yellow]Versión TLS en {version} (escritura omitida en modo prueba)[/yellow]",
+ "[yellow]El proceso del demonio falló durante la inicialización.[/yellow]",
+ "[yellow]El proceso del demonio salió de forma inesperada. Revise los registros del demonio.[/yellow]",
+ "[yellow]Suele indicar error de configuración, dependencia faltante o fallo de inicialización.[/yellow]",
+ "[yellow]Tiempo de espera del demonio agotado (último estado: {last_status})[/yellow]",
+ "[yellow]Para ver errores en la terminal, ejecute:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]",
+ "[yellow]Active el cifrado con --enable-encryption/--disable-encryption en download/magnet[/yellow]",
+ "[yellow]Torrent no encontrado en la cola[/yellow]",
+ "[yellow]Torrent no encontrado o inactivo. Los datos de reanudación se guardarán al completar el torrent.[/yellow]",
+ "[yellow]Torrent no encontrado[/yellow]",
+ "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load o --save[/yellow]",
+ "[yellow]Use -v para más detalles o --foreground para ver el error[/yellow]",
+ "[yellow]Advertencia: falló al guardar el punto de control[/yellow]",
+ "[yellow]Advertencia: los cambios requieren reiniciar el demonio, pero se omitió el reinicio.[/yellow]",
+ "[yellow]Advertencia: el demonio está en ejecución. El diagnóstico usará sesión local y puede haber conflictos de puerto.[/yellow]\n[dim]Considere detener el demonio primero: «btbt daemon exit»[/dim]\n",
+ "[yellow]Advertencia: error al guardar punto de control: {error}[/yellow]",
+ "[yellow]Advertencia: error al detener la sesión: {e}[/yellow]",
+ "[yellow]Advertencia: no se pudo guardar el punto de control: {error}[/yellow]",
+ "[yellow]Advertencia: no se pudieron seleccionar archivos: {error}[/yellow]",
+ "[yellow]Advertencia: no se pudo establecer la prioridad en cola: {error}[/yellow]",
+ "[yellow]Advertencia: cliente IPC no disponible[/yellow]",
+ "[yellow]Advertencia: la verificación SSL está desactivada mientras SSL se usa en modo estricto[/yellow]",
+ "[yellow]Advertencia: la generación de torrent v1 aún no está implementada.[/yellow]",
+ "[yellow]Advertencia: verificación de certificado desactivada con SSL en postura estricta[/yellow]",
+ "[yellow]Se eliminarían {count} puntos de control de más de {days} días:[/yellow]",
+ "[yellow]{key} no está definido[/yellow]",
+ "[yellow]⚠[/yellow] No se pudo guardar la configuración del demonio: {e}",
+ "[yellow]⚠[/yellow] Proceso del demonio iniciado (PID {pid}) pero puede no estar listo aún",
+ "[yellow]⚠[/yellow] Tiempo de espera de inicio del demonio tras {timeout:.1f}s (último estado: {last_status})",
+ "[yellow]⚠[/yellow] Se encontraron {errors} errores",
+ "[yellow]✓[/yellow] Protocolo Xet desactivado",
+ "[yellow]✓[/yellow] Transporte uTP desactivado",
+ "_get_executor() devolvió: executor=%s, is_daemon=%s",
+ "aiortc no instalado",
+ "desactivado",
+ "enable_dht={value}",
+ "enable_pex={value}",
+ "habilitado",
+ "fallido",
+ "bajó",
+ "http://tracker.example.com:8080/announce",
+ "no",
+ "ninguno",
+ "aún no listo",
+ "pares",
+ "piezas",
+ "replace: el archivo debe ser un documento completo válido; merge: fusión profunda en el TOML de destino y validar",
+ "subió",
+ "correcto",
+ "compartir tonic requiere el demonio. Inícielo con: btbt daemon start",
+ "uTP",
+ "uTP (protocolo de transporte uTorrent). Opciones:\n\nuTP ofrece entrega fiable y ordenada sobre UDP con control de congestión por retardo (BEP 29).\nÚtil en redes con alta latencia o pérdida de paquetes.",
+ "Configuración uTP",
+ "Config. uTP",
+ "Configuración uTP restablecida a valores predeterminados por CLI",
+ "Configuración uTP actualizada: %s = %s",
+ "Transporte uTP desactivado por CLI",
+ "Transporte uTP habilitado",
+ "Transporte uTP habilitado por CLI",
+ "desconocido",
+ "ilimitado",
+ "sí",
+ "{connection} Torrents: {torrents} Activos: {active} Pausados: {paused} Semilla: {seeding} D: {download}B/s S: {upload}B/s",
+ "{graph_tab_id} — error de configuración del proveedor de datos",
+ "{graph_tab_id} — proveedor de datos no disponible",
+ "hace {hours:.1f} h",
+ "{key} = {value}",
+ "{key}: {value}",
+ "hace {minutes:.0f} min",
+ "{msg}\n\nRuta del archivo PID: {path}",
+ "hace {seconds:.0f} s",
+ "Configuración de {sub_tab} — próximamente",
+ "Contenido de {sub_tab} para torrent {hash}… — próximamente",
+ "Configuración {type}",
+ "↑ Tasa",
+ "↑ Velocidad",
+ "↓ Tasa",
+ "↓ Velocidad",
+ "≥ 80 % disponible",
+ "⏸ Pausa",
+ "▶ Reanudar",
+ "⚠️ Hay que reiniciar el demonio para aplicar los cambios.\n",
+ "✓ La configuración es válida",
+ "✓ Sin advertencias de compatibilidad del sistema",
+ "✓ Verificar",
+ "✗ Validación de configuración fallida: {e}",
+ "📊 Actualizar PEX",
+ "📥 Exportar estado",
+ "🔄 Reanunciar",
+ "🔍 Rehash",
+ "🗑 Quitar"
+]
diff --git a/ccbt/i18n/locale_data/eu_supplement.json b/ccbt/i18n/locale_data/eu_supplement.json
new file mode 100644
index 00000000..667e3c87
--- /dev/null
+++ b/ccbt/i18n/locale_data/eu_supplement.json
@@ -0,0 +1 @@
+{"\n[bold]IP Filter Statistics[/bold]\n": "\n[bold]IP Filter Statistics[/bold]\n", "\n[bold]IP Filter Test[/bold]\n": "\n[bold]IP Filter Test[/bold]\n", "\n[cyan]Connection Diagnostics[/cyan]\n": "\n[cyan]Connection Diagnostics[/cyan]\n", "\n[cyan]Proxy Statistics:[/cyan]": "\n[cyan]Proxy Statistics:[/cyan]", "\n[cyan]Status:[/cyan] {status}": "\n[cyan]Status:[/cyan] {status}", "\n[dim]Press Ctrl+I in main dashboard to manage IPFS content and peers[/dim]": "\n[dim]Press Ctrl+I in main dashboard to manage IPFS content and peers[/dim]", "\n[dim]Press Ctrl+N in main dashboard to manage NAT settings globally[/dim]": "\n[dim]Press Ctrl+N in main dashboard to manage NAT settings globally[/dim]", "\n[dim]Press Ctrl+R in main dashboard to view scrape results[/dim]": "\n[dim]Press Ctrl+R in main dashboard to view scrape results[/dim]", "\n[dim]Press Ctrl+U in main dashboard to configure uTP settings globally[/dim]": "\n[dim]Press Ctrl+U in main dashboard to configure uTP settings globally[/dim]", "\n[dim]Press Ctrl+X in main dashboard to manage Xet settings globally[/dim]": "\n[dim]Press Ctrl+X in main dashboard to manage Xet settings globally[/dim]", "\n[green]Diagnostic complete![/green]": "\n[green]Diagnostic complete![/green]", "\n[green]✓ Discovery successful![/green]": "\n[green]✓ Discovery successful![/green]", "\n[green]✓[/green] No connection issues detected": "\n[green]✓[/green] No connection issues detected", "\n[yellow]2. DHT Status[/yellow]": "\n[yellow]2. DHT Status[/yellow]", "\n[yellow]3. Tracker Configuration[/yellow]": "\n[yellow]3. Tracker Configuration[/yellow]", "\n[yellow]4. NAT Configuration[/yellow]": "\n[yellow]4. NAT Configuration[/yellow]", "\n[yellow]5. Listen Port[/yellow]": "\n[yellow]5. Listen Port[/yellow]", "\n[yellow]6. Session Initialization Test[/yellow]": "\n[yellow]6. Session Initialization Test[/yellow]", "\n[yellow]Connection Issues[/yellow]": "\n[yellow]Connection Issues[/yellow]", "\n[yellow]Download interrupted by user[/yellow]": "\n[yellow]Download interrupted by user[/yellow]", "\n[yellow]Session Summary[/yellow]": "\n[yellow]Session Summary[/yellow]", "\n[yellow]Shutting down daemon...[/yellow]": "\n[yellow]Shutting down daemon...[/yellow]", "\n[yellow]TCP Server Status[/yellow]": "\n[yellow]TCP Server Status[/yellow]", "\n[yellow]✗ No NAT devices discovered[/yellow]": "\n[yellow]✗ No NAT devices discovered[/yellow]", " - {network} ({mode}, priority: {priority})": " - {network} ({mode}, priority: {priority})", " - {hash}... ({format})": "- {hash}... ({format})", " .tonic file: {path}": ".tonic fitxategia: {path}", " Active Downloading: {count}": " Active Downloading: {count}", " Active Mappings: {mappings}": " Active Mappings: {mappings}", " Active Seeding: {count}": " Active Seeding: {count}", " Add the peer first using 'tonic allowlist add'": " Add the peer first using 'tonic allowlist add'", " Auth failures: {count}": " Auth failures: {count}", " Auto Map Ports: {status}": " Auto Map Ports: {status}", " Bypass list: {value}": "Saihestu zerrenda: {value}", " Certificate: {path}": "Ziurtagiria: {path}", " Check interval: {seconds}": " Check interval: {seconds}", " Current mode: {mode}": "Uneko modua: {mode}", " DHT Enabled: {status}": " DHT Enabled: {status}", " DHT Port: {port}": "DHT ataka: {port}", " DHT Routing Table: {size} nodes": " DHT Routing Table: {size} nodes", " Default sync mode: {mode}": " Default sync mode: {mode}", " Enabled: {enabled}": "Gaituta: {enabled}", " External IP: {ip}": "Kanpoko IP: {ip}", " External: {port}": "Kanpokoa: {port}", " Failed: {count}": "Huts egin du: {count}", " Folder key: {folder_key}": " Folder key: {folder_key}", " Folder key: {key}": "Karpeta-gakoa: {key}", " For peers: {value}": "Ikaskideentzat: {value}", " For trackers: {value}": " For trackers: {value}", " For webseeds: {value}": " For webseeds: {value}", " HTTP Trackers: {status}": " HTTP Trackers: {status}", " Host: {host}:{port}": "Ostalaria: {host}:{port}", " Internal: {port}": "Barnekoa: {port}", " Key: {path}": "Gakoa: {path}", " Make sure NAT traversal is enabled and a device is discovered": " Make sure NAT traversal is enabled and a device is discovered", " Make sure NAT-PMP or UPnP is enabled on your router": " Make sure NAT-PMP or UPnP is enabled on your router", " Mode: {mode}": "Modua: {mode}", " NAT-PMP: {status}": "NAT-PMP: {status}", " Output directory: {dir}": " Output directory: {dir}", " Paused: {count}": "Pausatuta: {count}", " Protocol enabled: {enabled}": " Protocol enabled: {enabled}", " Protocol not active (session may not be running)": " Protocol not active (session may not be running)", " Protocol: {method}": "Protokoloa: {method}", " Protocol: {protocol}": "Protokoloa: {protocol}", " Queued: {count}": "Ilaran jarrita: {count}", " Running: {status}": "Korrika: {status}", " Serving: {status}": "Zerbitzaria: {status}", " Sessions with Peers: {count}": " Sessions with Peers: {count}", " Source peers: {peers}": " Source peers: {peers}", " Successful: {count}": "Arrakasta: {count}", " Supports DHT: {enabled}": " Supports DHT: {enabled}", " Supports PEX: {enabled}": " Supports PEX: {enabled}", " Supports XET: {enabled}": " Supports XET: {enabled}", " TCP Enabled: {status}": " TCP Enabled: {status}", " TCP Port: {port}": "TCP ataka: {port}", " Total Connections: {count}": " Total Connections: {count}", " Total Sessions: {count}": " Total Sessions: {count}", " Total connections: {count}": " Total connections: {count}", " Total: {count}": "Guztira: {count}", " Type: {type}": "Mota: {type}", " UDP Trackers: {status}": " UDP Trackers: {status}", " UPnP: {status}": "UPnP: {status}", " Use 'ccbt tonic status' to check sync status": " Use 'ccbt tonic status' to check sync status", " Username: {username}": "Erabiltzaile izena: {username}", " Workspace ID: {id}": "Laneko IDa: {id}", " Workspace sync enabled: {enabled}": " Workspace sync enabled: {enabled}", " XET port: {port}": "XET ataka: {port}", " [cyan]Allowed:[/cyan] {allows}": " [cyan]Allowed:[/cyan] {allows}", " [cyan]Blocked:[/cyan] {blocks}": " [cyan]Blocked:[/cyan] {blocks}", " [cyan]Enabled:[/cyan] {enabled}": " [cyan]Enabled:[/cyan] {enabled}", " [cyan]IP Address:[/cyan] {ip}": " [cyan]IP Address:[/cyan] {ip}", " [cyan]IPv4 Ranges:[/cyan] {ipv4_ranges}": " [cyan]IPv4 Ranges:[/cyan] {ipv4_ranges}", " [cyan]IPv6 Ranges:[/cyan] {ipv6_ranges}": " [cyan]IPv6 Ranges:[/cyan] {ipv6_ranges}", " [cyan]Last Update:[/cyan] Never": " [cyan]Last Update:[/cyan] Never", " [cyan]Last Update:[/cyan] {timestamp}": " [cyan]Last Update:[/cyan] {timestamp}", " [cyan]Mode:[/cyan] {mode}": " [cyan]Mode:[/cyan] {mode}", " [cyan]Status:[/cyan] {status}": " [cyan]Status:[/cyan] {status}", " [cyan]Total Checks:[/cyan] {matches}": " [cyan]Total Checks:[/cyan] {matches}", " [cyan]Total Rules:[/cyan] {total_rules}": " [cyan]Total Rules:[/cyan] {total_rules}", " [green]✓[/green] Can bind to port {port}": " [green]✓[/green] Can bind to port {port}", " [green]✓[/green] Session initialized successfully": " [green]✓[/green] Session initialized successfully", " [green]✓[/green] TCP server initialized": " [green]✓[/green] TCP server initialized", " [green]✓[/green] {url}: {loaded} rules": " [green]✓[/green] {url}: {loaded} rules", " [red]✗[/red] Cannot bind to port: {e}": " [red]✗[/red] Cannot bind to port: {e}", " [red]✗[/red] NAT manager not initialized": " [red]✗[/red] NAT manager not initialized", " [red]✗[/red] Session initialization failed: {e}": " [red]✗[/red] Session initialization failed: {e}", " [red]✗[/red] TCP server not initialized": " [red]✗[/red] TCP server not initialized", " [red]✗[/red] {url}: failed": " [red]✗[/red] {url}: failed", " [yellow]⚠[/yellow] DHT client not initialized": " [yellow]⚠[/yellow] DHT client not initialized", " [yellow]⚠[/yellow] TCP server not initialized": " [yellow]⚠[/yellow] TCP server not initialized", " uTP Enabled: {status}": " uTP Enabled: {status}", " {msg}": "{msg}", " {warning}": "{warning}", " ⚠ {warning}": "⚠ {warning}", " (checkpoint restored)": "(kontrol-puntua leheneratu da)", " (checkpoint saved)": "(kontrol-puntua gordeta)", " (no checkpoint found)": "(ez da kontrol punturik aurkitu)", " +{count} more": "+{count} gehiago", "(no options set)": "(ez dago aukerarik ezarri)", "- [yellow]{issue}[/yellow]": "- [yellow]{issue}[/yellow]", "- {id}: {severity} rule={rule} value={value}": "- {id}: {severity} rule={rule} value={value}", "- {name}: metric={metric}, cond={condition}, severity={severity}": "- {name}: metric={metric}, cond={condition}, severity={severity}", "... and {count} more": "... eta {count} gehiago", "0.1 ms (adaptive)": "0,1 ms (egokigarria)", "1 MB (adaptive)": "1 MB (egokigarria)", "1-2": "1-2", "2-4": "2-4", "25–49% available": "% 25-49 eskuragarri", "4-8": "4-8", "5 ms (adaptive)": "5 ms (egokigarria)", "50 ms (adaptive)": "50 ms (egokigarria)", "50–79% available": "% 50-79 eskuragarri", "512 KB (adaptive)": "512 KB (egokigarria)", "64 KB (adaptive)": "64 KB (egokigarria)", "ACK Interval": "ACK tartea", "ACK packet send interval": "ACK packet send interval", "API key or Ed25519 key manager required for WebSocket connection": "API key or Ed25519 key manager required for WebSocket connection", "Action": "Ekintza", "Actions": "Ekintzak", "Active Block Requests": "Bloke-eskaera aktiboak", "Active Nodes": "Nodo Aktiboak", "Active Torrents": "Torrent aktiboak", "Adaptive": "Egokigarria", "Add": "Gehitu", "Add Torrents": "Gehitu torrenteak", "Add Tracker": "Gehitu Tracker", "Add magnet succeeded but no info_hash returned": "Add magnet succeeded but no info_hash returned", "Add to Session": "Gehitu saioan", "Advanced": "Aurreratua", "Advanced add torrent": "Gehitu torrent aurreratua", "Advanced configuration (experimental features)": "Advanced configuration (experimental features)", "Advanced configuration - Data provider/Executor not available": "Advanced configuration - Data provider/Executor not available", "Aggressive": "Erasokorra", "Aggressive Mode": "Modu erasokorra", "Alerts dashboard": "Alertak panela", "All {total} file(s) verified successfully": "All {total} file(s) verified successfully", "Announce sent": "Iragarkia bidali da", "Apply": "Aplikatu", "Authentication failed when checking daemon status at %s (status %d). This usually indicates an API key mismatch. Check that the API key in config matches the daemon's API key.": "Authentication failed when checking daemon status at %s (status %d). This usually indicates an API key mismatch. Check that the API key in config matches the daemon's API key.", "Auto-scrape on Add:": "Gehitu automatikoki arrastatzea:", "Auto-tuned configuration saved to {path}": "Auto-tuned configuration saved to {path}", "Auto-tuning warnings:": "Sintonizazio automatikoaren abisuak:", "Availability": "Eskuragarritasuna", "Availability Trend": "Eskuragarritasun joera", "Availability {direction} {delta:+.1f}pp": "Availability {direction} {delta:+.1f}pp", "Available keys: {keys}": "Eskuragarri dauden gakoak: {keys}", "Available locales: {locales}": "Available locales: {locales}", "Average Quality": "Batez besteko Kalitatea", "Avg Download Rate": "Batez besteko deskarga-tasa", "Avg Quality": "Batez besteko kalitatea", "Avg Upload Rate": "Batez besteko igoera-tasa", "Backup complete": "Babeskopia osatuta", "Backup created: {path}": "Sortutako babeskopia: {path}", "Backup destination path": "Backup destination path", "Backup failed": "Babeskopia egiteak huts egin du", "Ban Peer": "Debekatu Peer", "Bandwidth": "Banda zabalera", "Bandwidth Utilization": "Banda-zabaleraren erabilera", "Bandwidth configuration - Data provider/Executor not available": "Bandwidth configuration - Data provider/Executor not available", "Blacklist Size": "Zerrenda beltzaren tamaina", "Blacklisted IPs ({count})": "Blacklisted IPs ({count})", "Blacklisted Peers": "Zerrenda beltzean dauden parekoak", "Block size (KiB)": "Blokearen tamaina (KiB)", "Blocked Connections": "Blokeatutako konexioak", "Bootstrap Nodes": "Bootstrap nodoak", "Bootstrap health": "Bootstrap osasuna", "Bootstrap recovery attempts": "Bootstrap recovery attempts", "Browse and add torrent": "Arakatu eta gehitu torrent", "Bytes Downloaded": "Deskargatutako byteak", "Bytes Uploaded": "Byteak kargatuta", "CPU": "CPU", "CRITICAL: PID file exists (initial=%s, current=%s, path=%s) but code reached local session creation! This will cause port conflicts. Aborting.": "CRITICAL: PID file exists (initial=%s, current=%s, path=%s) but code reached local session creation! This will cause port conflicts. Aborting.", "Cache Statistics": "Cachearen estatistikak", "Cache entries: {count}": "Cacheko sarrerak: {count}", "Cache hit rate: {rate:.2f}%": "Cache hit rate: {rate:.2f}%", "Cache size: {size} bytes": "Cache size: {size} bytes", "Cached Scrape Results": "Scrape emaitzak cachean", "Cached: {cache_size}, Total Seeders: {seeders}, Total Leechers: {leechers}": "Cached: {cache_size}, Total Seeders: {seeders}, Total Leechers: {leechers}", "Cancel": "Utzi", "Cancel Editing": "Utzi edizioa", "Cannot auto-resume checkpoint": "Cannot auto-resume checkpoint", "Cannot connect to daemon at %s: %s (daemon may not be running or IPC server not started)": "Cannot connect to daemon at %s: %s (daemon may not be running or IPC server not started)", "Cannot connect to daemon. Start daemon with: 'btbt daemon start'": "Cannot connect to daemon. Start daemon with: 'btbt daemon start'", "Cannot specify both --hybrid and --v1": "Cannot specify both --hybrid and --v1", "Cannot specify both --v2 and --hybrid": "Cannot specify both --v2 and --hybrid", "Cannot specify both --v2 and --v1": "Cannot specify both --v2 and --v1", "Catppuccin": "Catpuccin", "Checkpoint directory": "Checkpoint direktorioa", "Choked": "Itotuta", "Choose a playable file first.": "Choose a playable file first.", "Choose a theme": "Aukeratu gai bat", "Cleaning up old checkpoints...": "Cleaning up old checkpoints...", "Cleanup complete": "Garbiketa amaituta", "Click on 'Global' tab to configure this section": "Click on 'Global' tab to configure this section", "Client": "Bezeroa", "Client error checking daemon status at %s: %s (daemon may be starting up)": "Client error checking daemon status at %s: %s (daemon may be starting up)", "Close": "Itxi", "Closest Nodes": "Hurbilen dauden nodoak", "Command '{cmd}' executed successfully": "Command '{cmd}' executed successfully", "Command '{cmd}' failed": "'{cmd}' komandoak huts egin du", "Command executor not available": "Command executor not available", "Command executor or data provider not available": "Command executor or data provider not available", "Compress backup (default: yes)": "Compress backup (default: yes)", "Compressing backup...": "Babeskopia konprimitzen...", "Config": "Konfig", "Configuration": "Konfigurazioa", "Configuration differences:": "Configuration differences:", "Configuration exported to {path}": "Configuration exported to {path}", "Configuration imported to {path}": "Configuration imported to {path}", "Configuration options": "Konfigurazio aukerak", "Configuration restored from {path}": "Configuration restored from {path}", "Configuration saved successfully": "Configuration saved successfully", "Configuration saved successfully!": "Configuration saved successfully!", "Configuration saved successfully.\n": "Configuration saved successfully.\n", "Configuration section": "Konfigurazio atala", "Configuration: {type}\n\nThis configuration section is not yet fully implemented.": "Configuration: {type}\n\nThis configuration section is not yet fully implemented.", "Connected Torrents": "Konektatutako torrenteak", "Connected to {peers} peer(s), fetching metadata...": "Connected to {peers} peer(s), fetching metadata...", "Connecting to daemon at %s (PID file exists, config_path=%s)": "Connecting to daemon at %s (PID file exists, config_path=%s)", "Connecting to daemon at %s (config_path=%s)": "Connecting to daemon at %s (config_path=%s)", "Connecting to peers...": "Berdinekin konektatzen...", "Connection Duration": "Konexioaren Iraupena", "Connection Efficiency": "Konexioaren eraginkortasuna", "Connection Pool Statistics": "Connection Pool Statistics", "Connection Timeout": "Konexioaren denbora-muga", "Connection timeout (s)": "Konexioaren denbora-muga (k)", "Connection timeout in seconds": "Connection timeout in seconds", "Connections: {connections} | Packets: {sent}/{received} | Bytes: {bytes_sent}/{bytes_received}": "Connections: {connections} | Packets: {sent}/{received} | Bytes: {bytes_sent}/{bytes_received}", "Connections: {connections}, Signaling: {signaling} ({host}:{port})": "Connections: {connections}, Signaling: {signaling} ({host}:{port})", "Controls": "Kontrolak", "Copy Info Hash": "Kopiatu Info Hash", "Could not connect to daemon (no PID file): %s - will create local session": "Could not connect to daemon (no PID file): %s - will create local session", "Could not find file index": "Could not find file index", "Could not get torrent output directory": "Could not get torrent output directory", "Could not load torrent: {path}": "Could not load torrent: {path}", "Could not read daemon config from ConfigManager: %s": "Could not read daemon config from ConfigManager: %s", "Could not save daemon config to config file: %s": "Could not save daemon config to config file: %s", "Could not send shutdown request, using signal...": "Could not send shutdown request, using signal...", "Count": "zenbaketa", "Create Torrent": "Sortu Torrent", "Creating backup...": "Babeskopia sortzen...", "Cross-Torrent Sharing": "Cross-Torrent partekatzea", "Current": "Oraingoa", "Current Value": "Egungo balioa", "Current chunks: {count}": "Current chunks: {count}", "Current locale: {locale}": "Current locale: {locale}", "DHT Aggressive Mode:": "DHT modu erasokorra:", "DHT Health": "DHT Osasuna", "DHT Health (daemon)": "DHT Osasuna (daemon)", "DHT Health Hotspots": "DHT Osasun-guneak", "DHT Metrics": "DHTren neurketak", "DHT Statistics": "DHT Estatistikak", "DHT Status": "DHT egoera", "DHT aggressive mode {status}": "DHT aggressive mode {status}", "DHT client not available. DHT metrics require DHT to be enabled and running.": "DHT client not available. DHT metrics require DHT to be enabled and running.", "DHT data is unavailable in the current mode.": "DHT data is unavailable in the current mode.", "DHT is not running.": "DHT ez dago martxan.", "DHT is running but no active nodes yet.": "DHT is running but no active nodes yet.", "DHT is running. {active} active nodes, {peers} peers found.": "DHT is running. {active} active nodes, {peers} peers found.", "DHT port": "DHT ataka", "DHT timeout (s)": "DHT denbora-muga (k)", "Daemon PID file exists but API key not found (config or daemon config file). Cannot route to daemon. Please check daemon configuration.": "Daemon PID file exists but API key not found (config or daemon config file). Cannot route to daemon. Please check daemon configuration.", "Daemon PID file exists but cannot connect to daemon (error: {error}).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check if IPC server is running on the configured port\n 3. Verify API key in config matches daemon's API key\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but cannot connect to daemon (error: {error}).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check if IPC server is running on the configured port\n 3. Verify API key in config matches daemon's API key\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but cannot connect to daemon: {error}\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but cannot connect to daemon: {error}\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not accessible after {elapsed:.1f}s.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for startup errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not accessible after {elapsed:.1f}s.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for startup errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not responding (timeout after {elapsed:.1f}s).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not responding (timeout after {elapsed:.1f}s).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not responding after {max_total_wait:.1f}s.\nPossible causes:\n - Daemon is still starting up (wait a few seconds and try again)\n - Daemon crashed (check logs or run 'btbt daemon status')\n - IPC server is not accessible (check firewall/network settings)\n\nTo resolve:\n 1. Run 'btbt daemon status' to check if daemon is actually running\n 2. If daemon is not running, remove stale PID file: 'btbt daemon exit --force'\n 3. If you want to run locally instead, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not responding after {max_total_wait:.1f}s.\nPossible causes:\n - Daemon is still starting up (wait a few seconds and try again)\n - Daemon crashed (check logs or run 'btbt daemon status')\n - IPC server is not accessible (check firewall/network settings)\n\nTo resolve:\n 1. Run 'btbt daemon status' to check if daemon is actually running\n 2. If daemon is not running, remove stale PID file: 'btbt daemon exit --force'\n 3. If you want to run locally instead, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but error occurred while connecting: {error}.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for connection errors\n 3. Verify IPC server is accessible on the configured port\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but error occurred while connecting: {error}.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for connection errors\n 3. Verify IPC server is accessible on the configured port\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon connection error (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Daemon connection error (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...", "Daemon connection timeout (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Daemon connection timeout (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Daemon connection: config_path=%s, file_exists=%s": "Daemon connection: config_path=%s, file_exists=%s", "Daemon is accessible and ready (attempt %d/%d, took %.1fs)": "Daemon is accessible and ready (attempt %d/%d, took %.1fs)", "Daemon is marked as running but not accessible (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Daemon is marked as running but not accessible (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Daemon is marked as running but not accessible after %d attempts (elapsed %.1fs)": "Daemon is marked as running but not accessible after %d attempts (elapsed %.1fs)", "Daemon is not running": "Daemon ez dago martxan", "Daemon is not running, nothing to restart": "Daemon is not running, nothing to restart", "Daemon is not running, restart not needed": "Daemon is not running, restart not needed", "Daemon is not running. File management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. File management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. NAT management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. NAT management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. Queue management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. Queue management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. Scrape commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. Scrape commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon restarted successfully (PID: %d)": "Daemon restarted successfully (PID: %d)", "Daemon stopped": "Daemon gelditu egin zen", "Daemon stopped gracefully": "Daemon stopped gracefully", "Dark": "Iluna", "Dark Mode": "Modu iluna", "Dashboard Error": "Aginte-paneleko errorea", "Data": "Datuak", "Data provider or command executor not available": "Data provider or command executor not available", "Default": "Lehenetsia", "Default (Light)": "Lehenetsia (Arina)", "Delete torrent {info_hash}…? Press 'y' to confirm or 'n' to cancel": "Delete torrent {info_hash}…? Press 'y' to confirm or 'n' to cancel", "Depth": "Sakonera", "Description: {desc}": "Deskribapena: {desc}", "Deselect All": "Deshautatu Guztiak", "Deselect folder": "Deshautatu karpeta", "Deselected {count} file(s)": "Deselected {count} file(s)", "Diff written to {path}": "{path}-n idatzitako aldea", "Direct session access not available in daemon mode": "Direct session access not available in daemon mode", "Disable DHT": "Desgaitu DHT", "Disable HTTP trackers": "Desgaitu HTTP jarraitzaileak", "Disable IPv6": "Desgaitu IPv6", "Disable Protocol v2 (BEP 52)": "Disable Protocol v2 (BEP 52)", "Disable TCP transport": "Desgaitu TCP garraioa", "Disable TCP_NODELAY": "Desgaitu TCP_NODELAY", "Disable UDP trackers": "Desgaitu UDP jarraitzaileak", "Disable checkpointing": "Desgaitu kontrol-puntua", "Disable io_uring usage": "Desgaitu io_uring erabilera", "Disable memory mapping": "Desgaitu memoria mapaketa", "Disable metrics": "Desgaitu neurketak", "Disable protocol encryption": "Disable protocol encryption", "Disable sparse files": "Desgaitu fitxategi urriak", "Disable splash screen (useful for debugging)": "Disable splash screen (useful for debugging)", "Disable uTP transport": "Desgaitu uTP garraioa", "Disk": "Diskoa", "Disk I/O Configuration": "Disko I/O konfigurazioa", "Disk I/O Statistics": "Disko I/O Estatistikak", "Disk I/O configuration (preallocation, hashing, checkpoints)": "Disk I/O configuration (preallocation, hashing, checkpoints)", "Disk I/O metrics - Error: {error}": "Disk I/O metrics - Error: {error}", "Disk I/O workers": "Disko I/O langileak", "Disk IO": "Disko IO", "Disk Workers": "Disko Langileak", "Do Not Download": "Ez Deskargatu", "Down (B/s)": "Behera (B/s)", "Down/Up (B/s)": "Behera/Gora (B/s)", "Download Limit": "Deskargaren muga", "Download Limit (KiB/s):": "Download Limit (KiB/s):", "Download Rate": "Deskarga-tasa", "Download Rate Limit (bytes/sec, 0 = unlimited):": "Download Rate Limit (bytes/sec, 0 = unlimited):", "Download Trend": "Deskargatu Trend", "Download cancelled{checkpoint_info}": "Download cancelled{checkpoint_info}", "Download force started": "Deskargatzeko indarra hasi da", "Download limit (KiB/s, 0 = unlimited)": "Download limit (KiB/s, 0 = unlimited)", "Download paused{checkpoint_info}": "Download paused{checkpoint_info}", "Download resumed{checkpoint_info}": "Download resumed{checkpoint_info}", "Download swing {delta:.1f} KiB/s (peak {peak:.1f} KiB/s)": "Download swing {delta:.1f} KiB/s (peak {peak:.1f} KiB/s)", "Download:": "Deskargatu:", "Downloaders": "Deskargatzaileak", "Downloading": "Deskargatzen", "Dracula": "Drakula", "Duplicate Requests Prevented": "Duplicate Requests Prevented", "Duration": "Iraupena", "Editing: {section}": "Edizioa: {section}", "Enable Compression:": "Gaitu konpresioa:", "Enable DHT": "Gaitu DHT", "Enable Deduplication:": "Gaitu deduplicazioa:", "Enable HTTP trackers": "Gaitu HTTP jarraitzaileak", "Enable IPFS Protocol:": "Gaitu IPFS protokoloa:", "Enable IPv6": "Gaitu IPv6", "Enable NAT Port Mapping:": "Enable NAT Port Mapping:", "Enable P2P Content-Addressed Storage:": "Enable P2P Content-Addressed Storage:", "Enable Protocol v2 (BEP 52)": "Enable Protocol v2 (BEP 52)", "Enable TCP transport": "Gaitu TCP garraioa", "Enable TCP_NODELAY": "Gaitu TCP_NODELAY", "Enable UDP trackers": "Gaitu UDP jarraitzaileak", "Enable Xet Protocol:": "Gaitu Xet protokoloa:", "Enable debug mode (deprecated, use -vv)": "Enable debug mode (deprecated, use -vv)", "Enable debug verbosity (equivalent to -vv)": "Enable debug verbosity (equivalent to -vv)", "Enable direct I/O for writes when supported": "Enable direct I/O for writes when supported", "Enable fsync after batched writes": "Enable fsync after batched writes", "Enable io_uring on Linux if available": "Enable io_uring on Linux if available", "Enable metrics": "Gaitu neurketak", "Enable monitoring": "Gaitu monitorizazioa", "Enable protocol encryption": "Enable protocol encryption", "Enable sparse files": "Gaitu fitxategi urriak", "Enable streaming mode": "Gaitu streaming modua", "Enable trace verbosity (equivalent to -vvv)": "Enable trace verbosity (equivalent to -vvv)", "Enable uTP Transport:": "Gaitu uTP Garraioa:", "Enable uTP transport": "Gaitu uTP garraioa", "Enabled (Dependency Missing)": "Enabled (Dependency Missing)", "Enabled (Not Started)": "Gaituta (Ez da hasi)", "Encrypt backup with generated key": "Encrypt backup with generated key", "Encrypting backup...": "Babeskopia enkriptatzen...", "Endgame duplicate requests": "Endgame duplicate requests", "Endgame threshold (0..1)": "Endgame threshold (0..1)", "Enter Tracker URL": "Sartu Tracker URLa", "Enter path...": "Sartu bidea...", "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.": "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.", "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...": "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...", "Enter torrent file path or magnet link": "Enter torrent file path or magnet link", "Enter torrent file path or magnet link:": "Enter torrent file path or magnet link:", "Error": "Errorea", "Error adding tracker: {error}": "Error adding tracker: {error}", "Error banning peer: {error}": "Error banning peer: {error}", "Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...", "Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s": "Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s", "Error checking daemon stage: %s": "Error checking daemon stage: %s", "Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection": "Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection", "Error checking if restart is needed: %s": "Error checking if restart is needed: %s", "Error closing HTTP session: %s": "Error closing HTTP session: %s", "Error closing IPC client: %s": "Error closing IPC client: %s", "Error closing WebSocket: %s": "Error closing WebSocket: %s", "Error comparing configs: {e}": "Error comparing configs: {e}", "Error creating backup: {e}": "Error creating backup: {e}", "Error creating torrent": "Errore bat gertatu da torrent-a sortzean", "Error deselecting files: {error}": "Error deselecting files: {error}", "Error executing config.get command: {error}": "Error executing config.get command: {error}", "Error executing {operation} on daemon: {error}": "Error executing {operation} on daemon: {error}", "Error exporting configuration: {e}": "Error exporting configuration: {e}", "Error forcing announce: {error}": "Error forcing announce: {error}", "Error generating schema: {e}": "Error generating schema: {e}", "Error getting DHT stats: {error}": "Error getting DHT stats: {error}", "Error getting daemon status": "Error getting daemon status", "Error getting daemon status: %s": "Error getting daemon status: %s", "Error importing configuration: {e}": "Error importing configuration: {e}", "Error in socket pre-check: %s": "Error in socket pre-check: %s", "Error listing backups: {e}": "Error listing backups: {e}", "Error listing profiles: {e}": "Error listing profiles: {e}", "Error listing templates: {e}": "Error listing templates: {e}", "Error loading DHT data: {error}": "Error loading DHT data: {error}", "Error loading DHT summary: {error}": "Error loading DHT summary: {error}", "Error loading configuration: {error}": "Error loading configuration: {error}", "Error loading info: {error}": "Error loading info: {error}", "Error loading peer data: {error}": "Error loading peer data: {error}", "Error loading section: {error}": "Error loading section: {error}", "Error loading security data: {error}": "Error loading security data: {error}", "Error loading torrent config: {error}": "Error loading torrent config: {error}", "Error loading torrent: {error}": "Error loading torrent: {error}", "Error opening folder: {error}": "Error opening folder: {error}", "Error processing file %s: %s": "Error processing file %s: %s", "Error reading PID file after retries: %s": "Error reading PID file after retries: %s", "Error reading PID file: %s": "Error reading PID file: %s", "Error receiving WebSocket event: %s": "Error receiving WebSocket event: %s", "Error receiving WebSocket events batch: %s": "Error receiving WebSocket events batch: %s", "Error removing tracker: {error}": "Error removing tracker: {error}", "Error restarting daemon": "Error restarting daemon", "Error restoring backup: {e}": "Error restoring backup: {e}", "Error routing to daemon (PID file exists): %s": "Error routing to daemon (PID file exists): %s", "Error routing to daemon (no PID file): %s - will create local session": "Error routing to daemon (no PID file): %s - will create local session", "Error saving configuration: {error}": "Error saving configuration: {error}", "Error selecting files: {error}": "Error selecting files: {error}", "Error sending shutdown request: %s": "Error sending shutdown request: %s", "Error setting DHT aggressive mode: {error}": "Error setting DHT aggressive mode: {error}", "Error setting file priority: {error}": "Error setting file priority: {error}", "Error starting daemon": "Errore bat gertatu da daemon abiaraztean", "Error stopping daemon": "Errore bat gertatu da deabrua gelditzean", "Error stopping session: %s": "Error stopping session: %s", "Error submitting form: {error}": "Error submitting form: {error}", "Error verifying files: {error}": "Error verifying files: {error}", "Error waiting for daemon with progress: %s": "Error waiting for daemon with progress: %s", "Error waiting for daemon: %s": "Error waiting for daemon: %s", "Error waiting for metadata: %s": "Error waiting for metadata: %s", "Error with auto-tuning: {e}": "Error with auto-tuning: {e}", "Error with profile: {e}": "Error with profile: {e}", "Error with template: {e}": "Error with template: {e}", "Error: {error}": "Errorea: {error}", "Errors": "Akatsak", "Estimated Read Speed": "Irakurtzeko abiadura estimatua", "Estimated Write Speed": "Idazteko abiadura estimatua", "Events": "Gertaerak", "Eviction rate: {rate:.2f} /sec": "Eviction rate: {rate:.2f} /sec", "Exceeded maximum wait time (%.1fs) for daemon readiness": "Exceeded maximum wait time (%.1fs) for daemon readiness", "Excellent": "Bikaina", "Exists": "Existitzen da", "Expected info hash (hex)": "Expected info hash (hex)", "Expected type: {type_name}": "Expected type: {type_name}", "Export complete": "Esportatu amaitu da", "Exporting checkpoint...": "Exporting checkpoint...", "Failed Requests": "Eskaerak huts eginak", "Failed to add content": "Ezin izan da gehitu edukia", "Failed to add magnet link": "Failed to add magnet link", "Failed to add peer to allowlist": "Failed to add peer to allowlist", "Failed to add to queue": "Ezin izan da gehitu ilaran", "Failed to add torrent": "Ezin izan da torrent gehitzean", "Failed to add torrent to daemon": "Failed to add torrent to daemon", "Failed to add tracker": "Ezin izan da gehitu jarraitzailea", "Failed to add tracker: {error}": "Failed to add tracker: {error}", "Failed to announce: {error}": "Failed to announce: {error}", "Failed to ban peer: {error}": "Failed to ban peer: {error}", "Failed to calculate progress: %s": "Failed to calculate progress: %s", "Failed to cancel torrent": "Failed to cancel torrent", "Failed to cleanup Xet cache": "Failed to cleanup Xet cache", "Failed to clear queue": "Ezin izan da ilara garbitu", "Failed to collect custom metrics: %s": "Failed to collect custom metrics: %s", "Failed to collect performance metrics: %s": "Failed to collect performance metrics: %s", "Failed to collect system metrics: %s": "Failed to collect system metrics: %s", "Failed to copy info hash: {error}": "Failed to copy info hash: {error}", "Failed to deselect all files": "Failed to deselect all files", "Failed to deselect files": "Failed to deselect files", "Failed to deselect files: {error}": "Failed to deselect files: {error}", "Failed to disable io_uring: %s": "Failed to disable io_uring: %s", "Failed to discover NAT": "Ezin izan da aurkitu NAT", "Failed to enable io_uring: %s": "Failed to enable io_uring: %s", "Failed to force start all torrents": "Failed to force start all torrents", "Failed to force start torrent": "Failed to force start torrent", "Failed to generate .tonic file": "Failed to generate .tonic file", "Failed to generate tonic link": "Failed to generate tonic link", "Failed to get NAT status": "Failed to get NAT status", "Failed to get Xet cache info": "Failed to get Xet cache info", "Failed to get Xet stats": "Failed to get Xet stats", "Failed to get config: {error}": "Failed to get config: {error}", "Failed to get content": "Ezin izan da edukia lortu", "Failed to get metrics interval from config: %s": "Failed to get metrics interval from config: %s", "Failed to get peers": "Ezin izan dira parekoak lortu", "Failed to get per-peer rate limit": "Failed to get per-peer rate limit", "Failed to get queue": "Ezin izan da ilara lortu", "Failed to get stats": "Ezin izan dira lortu estatistikak", "Failed to get sync mode": "Failed to get sync mode", "Failed to get sync status": "Failed to get sync status", "Failed to launch media player": "Failed to launch media player", "Failed to list aliases": "Ezin izan dira aliasak zerrendatu", "Failed to list allowlist": "Failed to list allowlist", "Failed to list files": "Ezin izan dira fitxategiak zerrendatu", "Failed to list scrape results": "Failed to list scrape results", "Failed to load DHT health data: {error}": "Failed to load DHT health data: {error}", "Failed to load filter file: {file_path}": "Failed to load filter file: {file_path}", "Failed to load global KPIs: {error}": "Failed to load global KPIs: {error}", "Failed to load peer quality distribution: {error}": "Failed to load peer quality distribution: {error}", "Failed to load piece selection metrics: {error}": "Failed to load piece selection metrics: {error}", "Failed to load swarm timeline: {error}": "Failed to load swarm timeline: {error}", "Failed to map port": "Ezin izan da mapan portua", "Failed to move in queue": "Failed to move in queue", "Failed to parse config value: %s": "Failed to parse config value: %s", "Failed to pause all torrents": "Failed to pause all torrents", "Failed to pause torrent": "Failed to pause torrent", "Failed to pin content": "Ezin izan da ainguratu edukia", "Failed to refresh PEX": "Ezin izan da freskatu PEX", "Failed to refresh checkpoint": "Failed to refresh checkpoint", "Failed to refresh mappings": "Failed to refresh mappings", "Failed to refresh media state: {error}": "Failed to refresh media state: {error}", "Failed to reload checkpoint": "Failed to reload checkpoint", "Failed to remove alias": "Ezin izan da ezizena kendu", "Failed to remove from queue": "Failed to remove from queue", "Failed to remove peer from allowlist": "Failed to remove peer from allowlist", "Failed to remove tracker": "Failed to remove tracker", "Failed to remove tracker: {error}": "Failed to remove tracker: {error}", "Failed to resume all torrents": "Failed to resume all torrents", "Failed to resume torrent": "Failed to resume torrent", "Failed to save config: {error}": "Failed to save config: {error}", "Failed to save configuration to file: %s": "Failed to save configuration to file: %s", "Failed to scrape torrent": "Failed to scrape torrent", "Failed to select all files": "Failed to select all files", "Failed to select files": "Ezin izan dira fitxategiak hautatu", "Failed to select files: {error}": "Failed to select files: {error}", "Failed to set DHT aggressive mode": "Failed to set DHT aggressive mode", "Failed to set DHT aggressive mode: {error}": "Failed to set DHT aggressive mode: {error}", "Failed to set alias": "Ezin izan da ezizena ezarri", "Failed to set all peers rate limits": "Failed to set all peers rate limits", "Failed to set file priority": "Failed to set file priority", "Failed to set first piece priority: %s": "Failed to set first piece priority: %s", "Failed to set last piece priority: %s": "Failed to set last piece priority: %s", "Failed to set per-peer rate limit": "Failed to set per-peer rate limit", "Failed to set priority": "Ezin izan da ezarri lehentasuna", "Failed to set priority: {error}": "Failed to set priority: {error}", "Failed to set sync mode": "Failed to set sync mode", "Failed to share folder": "Ezin izan da karpeta partekatu", "Failed to sign WebSocket request: %s": "Failed to sign WebSocket request: %s", "Failed to sign request with Ed25519: %s": "Failed to sign request with Ed25519: %s", "Failed to start media stream": "Failed to start media stream", "Failed to start sync": "Ezin izan da sinkronizazioa abiarazi", "Failed to stop daemon": "Ezin izan da deabrua gelditu", "Failed to stop media stream": "Failed to stop media stream", "Failed to unmap port": "Ezin izan da mapa kendu", "Failed to unpin content": "Failed to unpin content", "Fair": "Azoka", "Fetching Metadata...": "Metadatuak eskuratzen...", "Fetching file list for selection. This may take a moment.": "Fetching file list for selection. This may take a moment.", "Field": "Eremua", "File Browser": "Fitxategien arakatzailea", "File Browser - Data provider or executor not available": "File Browser - Data provider or executor not available", "File Browser - Error: {error}": "File Browser - Error: {error}", "File Browser - Select files to create torrents": "File Browser - Select files to create torrents", "File Explorer": "Fitxategien arakatzailea", "File must have .torrent extension: %s": "File must have .torrent extension: %s", "File not found: %s": "Ez da aurkitu fitxategia: %s", "File {number}": "Fitxategia {number}", "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}": "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}", "Files in torrent {hash}...": "Files in torrent {hash}...", "Files: {count}": "Fitxategiak: {count}", "Filter update failed": "Ezin izan da iragazkia eguneratu", "Folder not found: {folder}": "Folder not found: {folder}", "Folder: {name}": "Karpeta: {name}", "Force Announce": "Indarra Iragarkia", "Force kill without graceful shutdown": "Force kill without graceful shutdown", "Found {count} potential issues": "Found {count} potential issues", "Full Path": "Bide osoa", "Full configuration editing requires navigating to the Global Config screen": "Full configuration editing requires navigating to the Global Config screen", "General": "Orokorra", "General configuration - Data provider/Executor not available": "General configuration - Data provider/Executor not available", "Generate new API key": "Sortu API gako berria", "Generated new API key for daemon": "Generated new API key for daemon", "Generating {format} torrent...": "Generating {format} torrent...", "GitHub Dark": "GitHub Dark", "Global": "Globala", "Global Configuration": "Konfigurazio globala", "Global Connected Peers": "Global Connected Peers", "Global KPIs": "KPI globalak", "Global KPIs data is unavailable in the current mode.": "Global KPIs data is unavailable in the current mode.", "Global Key Performance Indicators": "Global Key Performance Indicators", "Global Torrent Metrics": "Torrent neurketa globalak", "Global config": "Konfigurazio globala", "Global download limit (KiB/s)": "Global download limit (KiB/s)", "Global upload limit (KiB/s)": "Global upload limit (KiB/s)", "Good": "Ona", "Graceful shutdown timeout, forcing stop": "Graceful shutdown timeout, forcing stop", "Graphs": "Grafikoak", "Gruvbox": "Gruvbox", "HTTP error checking daemon status at %s: %s (status %d)": "HTTP error checking daemon status at %s: %s (status %d)", "Hash Chunk Size": "Hash Chunk tamaina", "Hash verification workers": "Hash verification workers", "Health": "Osasuna", "Help screen": "Laguntza pantaila", "High": "Alta", "Historical trends": "Joera historikoak", "Host for web interface": "Web interfazerako ostalaria", "IP Address": "IP helbidea", "IP filter not available": "IP filter not available", "IP:Port": "IP: Portua", "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)": "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)", "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.": "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.", "IPFS management": "IPFS kudeaketa", "Idle": "Geldirik", "Inactive": "Inaktibo", "Include effective runtime value from loaded config (file + env)": "Include effective runtime value from loaded config (file + env)", "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)": "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)", "Index": "Aurkibidea", "Info": "Info", "Info Hashes": "Informazio Hashak", "Info hash copied to clipboard": "Info hash copied to clipboard", "Info hash: {hash}": "Informazio hash: {hash}", "Initial Rate": "Hasierako Tarifa", "Initial send rate": "Hasierako bidalketa-tasa", "Invalid IP address: {error}": "Invalid IP address: {error}", "Invalid IP range: {ip_range}": "Invalid IP range: {ip_range}", "Invalid configuration after merge: {e}": "Invalid configuration after merge: {e}", "Invalid configuration: top-level must be an object": "Invalid configuration: top-level must be an object", "Invalid configuration: {e}": "Invalid configuration: {e}", "Invalid info hash format": "Invalid info hash format", "Invalid info hash format: %s": "Invalid info hash format: %s", "Invalid info hash format: {hash}": "Invalid info hash format: {hash}", "Invalid info hash length in magnet link": "Invalid info hash length in magnet link", "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu": "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu", "Invalid magnet link - missing 'xt=urn:btih:' parameter": "Invalid magnet link - missing 'xt=urn:btih:' parameter", "Invalid magnet link format": "Invalid magnet link format", "Invalid magnet link format - must start with 'magnet:?'": "Invalid magnet link format - must start with 'magnet:?'", "Invalid peer selection": "Parekideen hautaketa baliogabea", "Invalid profile '{name}': {errors}": "Invalid profile '{name}': {errors}", "Invalid template '{name}': {errors}": "Invalid template '{name}': {errors}", "Invalid tracker URL format. Must start with http://, https://, or udp://": "Invalid tracker URL format. Must start with http://, https://, or udp://", "Invalid tracker selection": "Invalid tracker selection", "Key Bindings": "Giltza-loturak", "Language": "Hizkuntza", "Last Error": "Azken errorea", "Last Update": "Azken eguneratzea", "Last sample {age}": "Azken lagina {age}", "Latency": "Latentzia", "Light": "Argia", "Light Mode": "Argi modua", "List available locales": "Zerrendatu eskuragarri dauden lokalak", "Listen interface": "Entzun interfazea", "Listen port": "Entzun ataka", "Loading configuration...": "Loading configuration...", "Loading file list…": "Fitxategien zerrenda kargatzen…", "Loading peer metrics...": "Loading peer metrics...", "Loading piece selection metrics...": "Loading piece selection metrics...", "Loading swarm timeline...": "Loading swarm timeline...", "Loading torrent information...": "Loading torrent information...", "Local Node Information": "Tokiko nodoen informazioa", "Low": "Baxua", "MMap cache size (MB)": "MMap cachearen tamaina (MB)", "MTU": "MTU", "Magnet command: PID file check - exists=%s, path=%s": "Magnet command: PID file check - exists=%s, path=%s", "Magnet link must contain 'xt=urn:btih:' parameter": "Magnet link must contain 'xt=urn:btih:' parameter", "Magnet link must start with 'magnet:?'": "Magnet link must start with 'magnet:?'", "Max Rate": "Gehienezko Tarifa", "Max Retransmits": "Gehienezko birtransmisioak", "Max Window Size": "Gehienezko leihoaren tamaina", "Maximum": "Gehienez", "Maximum UDP packet size": "Maximum UDP packet size", "Maximum block size (KiB)": "Maximum block size (KiB)", "Maximum download rate for this torrent": "Maximum download rate for this torrent", "Maximum global peers": "Gehienezko parekide globalak", "Maximum peers per torrent": "Maximum peers per torrent", "Maximum receive window size": "Maximum receive window size", "Maximum retransmission attempts": "Maximum retransmission attempts", "Maximum send rate": "Bidalketa-tasa maximoa", "Maximum upload rate for this torrent": "Maximum upload rate for this torrent", "Media": "Komunikabideak", "Media Playback": "Multimedia erreprodukzioa", "Media stream started.": "Multimedia igorpena hasi da.", "Media stream stopped.": "Multimedia igorpena gelditu da.", "Medium": "Ertaina", "Memory": "Memoria", "Metadata is loading. File selection will appear when available.": "Metadata is loading. File selection will appear when available.", "Metrics explorer": "Metrikoen esploratzailea", "Metrics interval (s)": "Metrikoen tartea (k)", "Metrics interval: {interval}s": "Metrics interval: {interval}s", "Metrics port": "Metrikoen ataka", "Migrating checkpoint format from {from_fmt} to {to_fmt}...": "Migrating checkpoint format from {from_fmt} to {to_fmt}...", "Migration complete": "Migrazioa amaituta", "Min Rate": "Gutxieneko tarifa", "Minimum block size (KiB)": "Minimum block size (KiB)", "Minimum send rate": "Bidalketa-tasa minimoa", "Mode": "Modua", "Model '{model}' not found in Config": "Model '{model}' not found in Config", "Modified": "Aldatua", "Monitoring": "Jarraipena", "Monokai": "Monokai", "N/A": "N/A", "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.": "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.", "NAT management": "NAT kudeaketa", "Name: {name}": "Izena: {name}", "Navigation": "Nabigazioa", "Navigation menu": "Nabigazio menua", "Network Configuration": "Sarearen konfigurazioa", "Network Optimization Recommendations": "Network Optimization Recommendations", "Network Performance": "Sarearen errendimendua", "Network configuration (connections, timeouts, rate limits)": "Network configuration (connections, timeouts, rate limits)", "Network configuration - Data provider/Executor not available": "Network configuration - Data provider/Executor not available", "Network quality": "Sarearen kalitatea", "Network quality - Error: {error}": "Network quality - Error: {error}", "Never": "Inoiz ez", "Next": "Hurrengoa", "Next Step": "Hurrengo urratsa", "No DHT metrics per torrent yet.": "No DHT metrics per torrent yet.", "No PID file found, checking for daemon via _get_executor()": "No PID file found, checking for daemon via _get_executor()", "No access": "Sarbiderik ez", "No active stream to stop.": "No active stream to stop.", "No availability data": "Ez dago erabilgarritasun-daturik", "No checkpoint found": "Ez da kontrol-punturik aurkitu", "No commands available": "Ez dago komandorik erabilgarri", "No configuration file to backup": "No configuration file to backup", "No daemon PID file found - daemon is not running": "No daemon PID file found - daemon is not running", "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s": "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s", "No file selected": "Ez da fitxategirik hautatu", "No files to deselect": "Ez dago desautatu beharreko fitxategirik", "No files to select": "Ez dago fitxategirik hautatzeko", "No locales directory found": "No locales directory found", "No magnet URI provided": "Ez da iman URIrik eman", "No magnet URI provided for add_magnet operation.": "No magnet URI provided for add_magnet operation.", "No metrics available": "Ez dago neurketarik", "No peer quality data available": "No peer quality data available", "No peer selected": "Ez da parekiderik hautatu", "No peers available": "Ez dago parekiderik erabilgarri", "No per-torrent data available": "No per-torrent data available", "No pieces": "Piezarik ez", "No playable files": "Ez dago erreproduzi daitekeen fitxategirik", "No playable media files were detected for this torrent.": "No playable media files were detected for this torrent.", "No recent security events.": "No recent security events.", "No section selected for editing": "No section selected for editing", "No significant events detected.": "No significant events detected.", "No swarm activity captured for the selected window.": "No swarm activity captured for the selected window.", "No swarm samples": "Ez dago lagin laginik", "No torrent data loaded. Please go back to step 1.": "No torrent data loaded. Please go back to step 1.", "No torrent path or magnet provided": "No torrent path or magnet provided", "No torrent path or magnet provided for add_torrent operation.": "No torrent path or magnet provided for add_torrent operation.", "No torrents with DHT activity yet.": "No torrents with DHT activity yet.", "No torrents yet. Use 'add' to start downloading.": "No torrents yet. Use 'add' to start downloading.", "No tracker selected": "Ez dago jarraitzailerik hautatu", "No trackers found": "Ez da aztarnatzailerik aurkitu", "Node ID": "Nodoaren IDa", "Node Information": "Nodoaren informazioa", "Node information not available.": "Node information not available.", "Nodes/Q": "Nodoak/Q", "Non-Empty Buckets": "Kubo Ez Hutsik", "Nord": "Nord", "Normal": "Normala", "Not enabled": "Ez dago gaituta", "Not enabled in configuration": "Not enabled in configuration", "Not initialized": "Hasieratu gabe", "Note": "Oharra", "Number of pieces to verify for integrity (0 = disable)": "Number of pieces to verify for integrity (0 = disable)", "OK (dry-run — configuration is valid)": "OK (dry-run — configuration is valid)", "OK (dry-run — merged configuration is valid)": "OK (dry-run — merged configuration is valid)", "One Dark": "Ilun bat", "Only options in this top-level section (e.g. network)": "Only options in this top-level section (e.g. network)", "Only paths starting with this prefix": "Only paths starting with this prefix", "Open File": "Ireki Fitxategia", "Open Folder": "Ireki Karpeta", "Open in VLC": "Ireki VLCn", "Opened folder: {path}": "Irekitako karpeta: {path}", "Opened stream in external player via {method}.": "Opened stream in external player via {method}.", "Optimistic unchoke interval (s)": "Optimistic unchoke interval (s)", "Option": "Aukera", "Others can join with: ccbt tonic sync \"{link}\" --output ": "Others can join with: ccbt tonic sync \"{link}\" --output ", "Output Directory": "Irteera direktorioa", "Output directory": "Irteera direktorioa", "Output directory (default: current directory)": "Output directory (default: current directory)", "Output directory not available": "Output directory not available", "Output file path": "Irteerako fitxategiaren bidea", "Output format for the option catalog": "Output format for the option catalog", "Overall Efficiency": "Eraginkortasun orokorra", "Overall Health": "Osasun orokorra", "Override IPC server port": "Override IPC server port", "PEX interval (s)": "PEX tartea (k)", "PEX refresh failed: {error}": "PEX refresh failed: {error}", "PEX refresh requested": "PEX freskatzea eskatu da", "PEX: Failed": "PEX: Huts egin du", "PID file contains invalid PID: %d, removing": "PID file contains invalid PID: %d, removing", "PID file contains invalid data: %r, removing": "PID file contains invalid data: %r, removing", "PID file is empty, removing": "PID file is empty, removing", "Parsing files and building file tree...": "Parsing files and building file tree...", "Parsing files and building hybrid metadata...": "Parsing files and building hybrid metadata...", "Patch file format (auto: infer from extension or try JSON then TOML)": "Patch file format (auto: infer from extension or try JSON then TOML)", "Patch must be a JSON/TOML object at the top level": "Patch must be a JSON/TOML object at the top level", "Path": "Bidea", "Path does not exist": "Bidea ez da existitzen", "Path is not a file: %s": "Path is not a file: %s", "Path or magnet://...": "Bidea edo imana://...", "Path to config file": "Konfigurazio fitxategirako bidea", "Pause failed: {error}": "Pausaldiak huts egin du: {error}", "Pause torrent": "Pausatu torrent", "Paused": "Pausatuta", "Paused {info_hash}…": "Pausatuta {info_hash}…", "Peer": "Parekidea", "Peer Details": "Peer Xehetasunak", "Peer Distribution": "Berdinen arteko banaketa", "Peer Efficiency": "Parekideen Eraginkortasuna", "Peer Quality": "Pareko Kalitatea", "Peer Quality Distribution": "Peer Quality Distribution", "Peer Selection": "Parekideen hautaketa", "Peer banning not yet implemented. Selected peer: {ip}:{port}": "Peer banning not yet implemented. Selected peer: {ip}:{port}", "Peer distribution - Error: {error}": "Peer distribution - Error: {error}", "Peer not found": "Ez da parekoa aurkitu", "Peer quality - Error: {error}": "Peer quality - Error: {error}", "Peer quality data is unavailable in the current mode.": "Peer quality data is unavailable in the current mode.", "Peer timeout (s)": "Parekideen denbora-muga (k)", "Peer {ip}:{port} banned": "Peer {ip}:{port} banned", "Peers Found": "Parekideak aurkitu", "Peers/Q": "Parekoak/Q", "Per-Peer": "Pareko", "Per-Peer tab - Data provider or executor not available": "Per-Peer tab - Data provider or executor not available", "Per-Torrent": "Torrenteko", "Per-Torrent Config: {hash}...": "Per-Torrent Config: {hash}...", "Per-Torrent Configuration": "Per-Torrent Configuration", "Per-Torrent Configuration: {name}": "Per-Torrent Configuration: {name}", "Per-Torrent Quality Summary": "Per-Torrent Quality Summary", "Per-Torrent tab - Data provider or executor not available": "Per-Torrent tab - Data provider or executor not available", "Per-torrent DHT": "Torrent bakoitzeko DHT", "Per-torrent configuration - Data provider/Executor or torrent not available": "Per-torrent configuration - Data provider/Executor or torrent not available", "Per-torrent configuration saved successfully": "Per-torrent configuration saved successfully", "Percentage": "Ehunekoa", "Performance metrics": "Errendimendu-neurriak", "Performance metrics - Error: {error}": "Performance metrics - Error: {error}", "Permission denied": "Baimena ukatu egin da", "Piece Selection Strategy": "Piece Selection Strategy", "Piece selection metrics are not available yet for this torrent.": "Piece selection metrics are not available yet for this torrent.", "Piece selection metrics are unavailable in the current mode.": "Piece selection metrics are unavailable in the current mode.", "Pieces Received": "Jasotako piezak", "Pieces Served": "Zerbitzatutako piezak", "Pin Content in IPFS:": "Ainguratu edukia IPFSn:", "Pipeline Rejections": "Pipeline ukatzeak", "Pipeline Utilization": "Hodibideen erabilera", "Please enter a torrent path or magnet link": "Please enter a torrent path or magnet link", "Please fix parse errors before saving": "Please fix parse errors before saving", "Please fix validation errors before saving": "Please fix validation errors before saving", "Please select a torrent first": "Please select a torrent first", "Poor": "Pobrea", "Port for web interface": "Port for web interface", "Port: {port}, STUN: {stun_count} server(s)": "Port: {port}, STUN: {stun_count} server(s)", "Prefer Protocol v2 when available": "Prefer Protocol v2 when available", "Prefer over TCP": "Nahiago TCP baino", "Prefer uTP when both TCP and uTP are available": "Prefer uTP when both TCP and uTP are available", "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s": "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s", "Press Ctrl+C to stop the daemon": "Press Ctrl+C to stop the daemon", "Press Enter to configure this section": "Press Enter to configure this section", "Previous": "Aurrekoa", "Previous Step": "Aurreko urratsa", "Prioritize first piece": "Prioritize first piece", "Prioritize last piece": "Lehenetsi azken pieza", "Prioritized Pieces": "Lehenetsitako piezak", "Priority (0 = normal, 1 = high, -1 = low):": "Priority (0 = normal, 1 = high, -1 = low):", "Priority level": "Lehentasun maila", "Profile '{name}' not found": "Profile '{name}' not found", "Profile applied to {path}": "Profile applied to {path}", "Profile config written to {path}": "Profile config written to {path}", "Profile: {name}": "Profila: {name}", "Protocol v2 (BEP 52)": "Protokoloa v2 (BEP 52)", "Protocols (Ctrl+)": "Protokoloak (Ktrl+)", "Provide a VALUE argument or use --value=... for values with spaces or JSON": "Provide a VALUE argument or use --value=... for values with spaces or JSON", "Proxy config": "Proxy konfigurazioa", "Public key must be 32 bytes (64 hex characters)": "Public key must be 32 bytes (64 hex characters)", "PyYAML is required for YAML export": "PyYAML is required for YAML export", "PyYAML is required for YAML import": "PyYAML is required for YAML import", "PyYAML is required for YAML patches": "PyYAML is required for YAML patches", "Quality": "Kalitatea", "Quality Distribution": "Kalitatearen Banaketa", "Queries": "Kontsultak", "Queries Received": "Jasotako kontsultak", "Queries Sent": "Bidalitako kontsultak", "Quick Add Torrent": "Gehitu azkar torrent", "Quick Stats": "Estatistika azkarrak", "Quick add torrent": "Gehitu azkar torrent", "RTT multiplier for retransmit timeout": "RTT multiplier for retransmit timeout", "Rainbow": "Ortzadarra", "Rate Limits (KiB/s)": "Tarifaren mugak (KiB/s)", "Rate limit configuration (global and per-torrent)": "Rate limit configuration (global and per-torrent)", "Rates": "Tarifak", "Read IPC port %d from daemon config file (authoritative source)": "Read IPC port %d from daemon config file (authoritative source)", "Recent Security Events ({count})": "Recent Security Events ({count})", "Recommended Settings": "Gomendatutako ezarpenak", "Recommended Value": "Gomendatutako balioa", "Reconnect to peers from checkpoint": "Reconnect to peers from checkpoint", "Recovery & Pipeline Health": "Recovery & Pipeline Health", "Refresh": "Freskatu", "Refresh PEX": "Freskatu PEX", "Refresh tracker state from checkpoint": "Refresh tracker state from checkpoint", "Rehash: Failed": "Rehash: huts egin du", "Remaining chunks: {count}": "Remaining chunks: {count}", "Remove": "Kendu", "Remove Tracker": "Kendu Tracker", "Remove checkpoints older than N days": "Remove checkpoints older than N days", "Remove failed: {error}": "Remove failed: {error}", "Remove tracker not yet implemented. Selected tracker: {url}": "Remove tracker not yet implemented. Selected tracker: {url}", "Reputation Tracking": "Ospearen jarraipena", "Request Efficiency": "Eraginkortasuna eskatu", "Request Latency": "Eskatu Latentzia", "Request Success": "Arrakasta eskatzea", "Request pipeline depth": "Request pipeline depth", "Required": "Beharrezkoa", "Reset specific key only (otherwise resets all options)": "Reset specific key only (otherwise resets all options)", "Resource": "Baliabidea", "Resource Utilization": "Baliabideen Erabilera", "Responses Received": "Jasotako erantzunak", "Restart Required": "Berrabiarazi Beharrezkoa", "Restart daemon now?": "Berrabiarazi deabrua orain?", "Restore complete": "Berreskuratu osatuta", "Restore failed": "Berreskuratu huts egin du", "Restoring checkpoint...": "Restoring checkpoint...", "Resume failed: {error}": "Resume failed: {error}", "Resume from checkpoint if available": "Resume from checkpoint if available", "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.": "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.", "Resume from checkpoint:": "Resume from checkpoint:", "Resume from checkpoint?": "Resume from checkpoint?", "Resume torrent": "Berrekin torrent", "Resumed {info_hash}…": "Berekin da {info_hash}…", "Resuming {name}": "{name} berriz", "Retransmit Timeout Factor": "Retransmit Timeout Factor", "Routing Table": "Bideratze Taula", "Routing table statistics not available.": "Routing table statistics not available.", "Rule not found: {ip_range}": "Rule not found: {ip_range}", "Run additional system compatibility checks after model validation": "Run additional system compatibility checks after model validation", "Run in foreground (for debugging)": "Run in foreground (for debugging)", "SSL config": "SSL konfigurazioa", "Save Config": "Gorde konfigurazioa", "Save Configuration": "Gorde konfigurazioa", "Save checkpoint after reset": "Save checkpoint after reset", "Save checkpoint immediately after setting option": "Save checkpoint immediately after setting option", "Saving torrent to {path}...": "Saving torrent to {path}...", "Scanning folder and calculating chunks...": "Scanning folder and calculating chunks...", "Schema written to {path}": "Schema written to {path}", "Scrape": "Arrastatu", "Scrape Count": "Scrape Count", "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.": "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.", "Scrape results": "Arrastatu emaitzak", "Scrape: Failed": "Scrape: huts egin du", "Search torrents...": "Bilatu torrentak...", "Section": "atala", "Section '{section}' is not a configuration section": "Section '{section}' is not a configuration section", "Section '{section}' not found": "Section '{section}' not found", "Section: {section}": "Atala: {section}", "Security": "Segurtasuna", "Security Events": "Segurtasun Gertaerak", "Security Scan Status": "Segurtasun-eskanearen egoera", "Security Statistics": "Segurtasun Estatistikak", "Security configuration - Data provider/Executor not available": "Security configuration - Data provider/Executor not available", "Security manager not available. Security scanning requires local session mode.": "Security manager not available. Security scanning requires local session mode.", "Security scan": "Segurtasun eskaneatzea", "Security scan completed. No issues detected.": "Security scan completed. No issues detected.", "Security scan completed. {blocked} blocked connections, {events} security events detected.": "Security scan completed. {blocked} blocked connections, {events} security events detected.", "Security scan is not available when connected to daemon.": "Security scan is not available when connected to daemon.", "Security settings (encryption, IP filtering, SSL)": "Security settings (encryption, IP filtering, SSL)", "Seeding": "Ereintzea", "Seeds": "Haziak", "Select": "Hautatu", "Select All": "Hautatu Guztiak", "Select File Priority": "Hautatu Fitxategien lehentasuna", "Select Files to Download": "Select Files to Download", "Select Language": "Hautatu Hizkuntza", "Select Priority": "Hautatu Lehentasuna", "Select Section": "Hautatu Atala", "Select Theme": "Hautatu Gaia", "Select a graph type to view": "Select a graph type to view", "Select a section to configure": "Select a section to configure", "Select a section to configure. Press Enter to edit, Escape to go back.": "Select a section to configure. Press Enter to edit, Escape to go back.", "Select a sub-tab to view configuration options": "Select a sub-tab to view configuration options", "Select a sub-tab to view torrents": "Select a sub-tab to view torrents", "Select a torrent and sub-tab to view details": "Select a torrent and sub-tab to view details", "Select a torrent insight tab": "Select a torrent insight tab", "Select a workflow tab": "Hautatu lan-fluxuaren fitxa", "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all": "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all", "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)": "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)", "Select folder": "Hautatu karpeta", "Select playable file": "Hautatu erreproduzi daitekeen fitxategia", "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.": "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.", "Select torrent...": "Hautatu torrent...", "Selected {count} file(s)": "Selected {count} file(s)", "Set Limits": "Ezarri Mugak", "Set Priority": "Ezarri Lehentasuna", "Set locale (e.g., 'en', 'es', 'fr')": "Set locale (e.g., 'en', 'es', 'fr')", "Set priority to {priority} for file": "Set priority to {priority} for file", "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.": "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.", "Setting": "Ezarpena", "Share Ratio": "Partekatzeko ratioa", "Share failed": "Ezin izan da partekatu", "Shared Peers": "Parekatuak", "Show checkpoints in specific format": "Show checkpoints in specific format", "Show what would be deleted without actually deleting": "Show what would be deleted without actually deleting", "Shutdown timeout in seconds": "Shutdown timeout in seconds", "Size: {size}": "Tamaina: {size}", "Skip & Continue": "Saltatu eta Jarraitu", "Skip waiting and select all files": "Skip waiting and select all files", "Socket Optimizations": "Socket optimizazioak", "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.": "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.", "Socket manager not initialized": "Socket manager not initialized", "Socket receive buffer (KiB)": "Socket receive buffer (KiB)", "Socket send buffer (KiB)": "Socket send buffer (KiB)", "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.": "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.", "Solarized Dark": "Eguzki-iluna", "Solarized Light": "Eguzki-argia", "Source path does not exist: %s": "Source path does not exist: %s", "Speed Category": "Abiadura Kategoria", "Speeds": "Abiadurak", "Start Stream": "Hasi korrontea", "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.": "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.", "Start daemon in background without waiting for completion (faster startup)": "Start daemon in background without waiting for completion (faster startup)", "Start interactive mode": "Start interactive mode", "Start the stream before opening VLC.": "Start the stream before opening VLC.", "Starting daemon...": "Deabrua hasten...", "Starting file verification...": "Starting file verification...", "State: stopped\nSelected file index: {index}": "State: stopped\nSelected file index: {index}", "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}": "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}", "Step {current}/{total}: {steps}": "Step {current}/{total}: {steps}", "Stop Stream": "Gelditu korrontea", "Stopped": "Gelditu", "Stopping daemon for restart...": "Stopping daemon for restart...", "Stopping daemon...": "Deabrua gelditzen...", "Stopping daemon... ({elapsed:.1f}s)": "Stopping daemon... ({elapsed:.1f}s)", "Storage": "Biltegiratzea", "Storage Device Detection": "Storage Device Detection", "Storage Type": "Biltegiratze mota", "Storage configuration - Data provider/Executor not available": "Storage configuration - Data provider/Executor not available", "Strategy": "Estrategia", "Stuck Pieces Recovered": "Stuck Pieces Recovered", "Submit": "Bidali", "Success": "Arrakasta", "Successful Requests": "Eskaerak arrakastatsuak", "Summary": "Laburpena", "Supported MVP playback targets include common audio/video files.": "Supported MVP playback targets include common audio/video files.", "Swarm Health": "Swarm Osasuna", "Swarm Timeline": "Swarm Timeline", "Swarm health - Error: {error}": "Swarm health - Error: {error}", "Swarm timeline - Error: {error}": "Swarm timeline - Error: {error}", "System Efficiency": "Sistemaren eraginkortasuna", "System recommendations:": "System recommendations:", "System resources": "Sistemaren baliabideak", "System resources - Error: {error}": "System resources - Error: {error}", "Template '{name}' not found": "Template '{name}' not found", "Template applied to {path}": "Template applied to {path}", "Template config written to {path}": "Template config written to {path}", "Template: {name}": "Txantiloia: {name}", "Templates: {templates}": "Templates: {templates}", "Textual Dark": "Testu Iluna", "Theme": "Gaia", "Theme: {theme}": "Gaia: {theme}", "This torrent has no files to select.": "This torrent has no files to select.", "This will modify your configuration file. Continue?": "This will modify your configuration file. Continue?", "Tier": "Maila", "Time": "Denbora", "Timeline": "Denbora-lerroa", "Timeline data is unavailable in the current mode.": "Timeline data is unavailable in the current mode.", "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)": "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)", "Timeout checking daemon status at %s (daemon may be starting up or overloaded)": "Timeout checking daemon status at %s (daemon may be starting up or overloaded)", "Tip: full option catalog and file merge → ": "Tip: full option catalog and file merge → ", "Toggle Dark/Light": "Aktibatu Iluna/Argia", "Tokyo Night": "Tokioko gaua", "Top 10 Peers by Quality": "Top 10 Peers by Quality", "Top profile entries:": "Profileko sarrera nagusiak:", "Torrent": "Torrent", "Torrent Control": "Torrent Kontrola", "Torrent Controls": "Torrent Kontrolak", "Torrent Controls - Data provider or executor not available": "Torrent Controls - Data provider or executor not available", "Torrent Controls - Error: {error}": "Torrent Controls - Error: {error}", "Torrent File Explorer": "Torrent fitxategien arakatzailea", "Torrent Information": "Torrent informazioa", "Torrent config": "Torrent konfigurazioa", "Torrent file is empty: %s": "Torrent file is empty: %s", "Torrent file not found: %s": "Torrent file not found: %s", "Torrent paused": "Torrentek pausatu egin zuen", "Torrent priority": "Torrent lehentasuna", "Torrent removed": "Torrent kendu da", "Torrent resumed": "Torrentek berriro jarraitu zuen", "Torrent saved to {path}": "Torrent saved to {path}", "Torrents tab - Data provider or executor not available": "Torrents tab - Data provider or executor not available", "Torrents with DHT": "DHTrekin torrenteak", "Total Buckets": "Kuboak guztira", "Total Connections": "Konexioak guztira", "Total Downloaded": "Deskargatu guztira", "Total Nodes": "Nodoak guztira", "Total Peers": "Guztira Parekoak", "Total Peers: {total} | Active Peers: {active}": "Total Peers: {total} | Active Peers: {active}", "Total Queries": "Kontsultak guztira", "Total Requests": "Eskaerak guztira", "Total Size": "Tamaina osoa", "Total Uploaded": "Kargatutako guztira", "Total chunks: {count}": "Zatiak guztira: {count}", "Total queries": "Kontsultak guztira", "Tracker": "Jarraitzailea", "Tracker Error": "Jarraitzailearen errorea", "Tracker added: {url}": "Jarraitzailea gehitu da: {url}", "Tracker announce interval (s)": "Tracker announce interval (s)", "Tracker removed: {url}": "Tracker removed: {url}", "Tracker scrape interval (s)": "Tracker scrape interval (s)", "Trackers": "Jarraitzaileak", "Tracking {count} torrent(s) across {minutes} minute window": "Tracking {count} torrent(s) across {minutes} minute window", "Trend: {trend} ({delta:+.1f}pp)": "Trend: {trend} ({delta:+.1f}pp)", "UI refresh interval: {interval}s": "UI refresh interval: {interval}s", "URL": "URLa", "Unavailable": "Ez dago erabilgarri", "Unchoke interval (s)": "Askatu tartea (k)", "Unexpected error checking daemon status at %s: %s": "Unexpected error checking daemon status at %s: %s", "Unknown error": "Errore ezezaguna", "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.": "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.", "Unknown operation: %s": "Eragiketa ezezaguna: %s", "Unlimited": "Mugagabea", "Up (B/s)": "Gora (B/s)", "Updated at {time}": "{time} helbidean eguneratua", "Updated config file with daemon configuration": "Updated config file with daemon configuration", "Upload Limit": "Kargatzeko muga", "Upload Limit (KiB/s):": "Kargatzeko muga (KiB/s):", "Upload Rate": "Kargatze-tasa", "Upload Rate Limit (bytes/sec, 0 = unlimited):": "Upload Rate Limit (bytes/sec, 0 = unlimited):", "Upload limit (KiB/s, 0 = unlimited)": "Upload limit (KiB/s, 0 = unlimited)", "Upload:": "Kargatu:", "Uploaded": "Kargatu da", "Uploading": "Kargatzen", "Uptime": "Epea", "Usage": "Erabilera", "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema": "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema", "Usage: disk [show|stats|config |monitor]": "Usage: disk [show|stats|config |monitor]", "Usage: network [show|stats|config |optimize|monitor]": "Usage: network [show|stats|config |optimize|monitor]", "Use 'btbt daemon restart' or restart the daemon manually.": "Use 'btbt daemon restart' or restart the daemon manually.", "Use --confirm to proceed with restore": "Use --confirm to proceed with restore", "Use --force to force kill": "Use --force to force kill", "Use Protocol v2 only (disable v1)": "Use Protocol v2 only (disable v1)", "Use memory mapping": "Erabili memoria mapak", "Using IPC port %d from main config": "Using IPC port %d from main config", "Using daemon config file: port=%d, api_key_present=%s": "Using daemon config file: port=%d, api_key_present=%s", "Using daemon executor for magnet command": "Using daemon executor for magnet command", "Using default IPC port %d (daemon config file may not exist)": "Using default IPC port %d (daemon config file may not exist)", "Utilization Median": "Erabilpenaren mediana", "Utilization Range": "Erabilera-tartea", "Utilization Samples": "Erabilera-laginak", "V1 torrent generation not yet implemented": "V1 torrent generation not yet implemented", "VS Code Dark": "VS Code Dark", "Validate merged file overlay only; do not write": "Validate merged file overlay only; do not write", "Validate only; do not write the config file": "Validate only; do not write the config file", "Validation error: %s": "Balioztatze-errorea: %s", "Value to set (use for strings with spaces or JSON); overrides positional VALUE": "Value to set (use for strings with spaces or JSON); overrides positional VALUE", "Verification complete: {verified} verified, {failed} failed out of {total}": "Verification complete: {verified} verified, {failed} failed out of {total}", "Verification failed: {error}": "Verification failed: {error}", "Verify Files": "Egiaztatu fitxategiak", "Visual": "Ikusgarria", "Wait for Metadata": "Itxaron metadatuak", "Wait for metadata and prompt for file selection (interactive only)": "Wait for metadata and prompt for file selection (interactive only)", "Warnings:": "Abisuak:", "WebSocket error in batch receive: %s": "WebSocket error in batch receive: %s", "WebSocket error: %s": "WebSocket errorea: %s", "WebSocket receive loop error: %s": "WebSocket receive loop error: %s", "WebTorrent": "WebTorrent", "Whitelist Size": "Zerrenda zuriaren tamaina", "Whitelisted Peers": "Zerrenda zuriko kideek", "Windows-specific error checking daemon (os.kill() issue): %s - no PID file found, will create local session": "Windows-specific error checking daemon (os.kill() issue): %s - no PID file found, will create local session", "Write Batch Timeout": "Idatzi Batch Timeout", "Write batch size (KiB)": "Write batch size (KiB)", "Write buffer size (KiB)": "Write buffer size (KiB)", "Write merged config to global config file": "Write merged config to global config file", "Write merged config to project local ccbt.toml": "Write merged config to project local ccbt.toml", "Write-Back Cache": "Idazketa-itzultzeko cachea", "Writing export file...": "Writing export file...", "Wrote catalog to {path}": "Wrote catalog to {path}", "XET Folders": "XET Karpetak", "Xet Protocol Options:\n\nXet enables content-defined chunking and deduplication.\nUseful for reducing storage when downloading similar content.": "Xet Protocol Options:\n\nXet enables content-defined chunking and deduplication.\nUseful for reducing storage when downloading similar content.", "Xet management": "Xet kudeaketa", "You can skip waiting and continue with all files selected.": "You can skip waiting and continue with all files selected.", "Zero-state count": "Zero-egoera zenbaketa", "[blue]Progress: {verified}/{total} pieces verified[/blue]": "[blue]Progress: {verified}/{total} pieces verified[/blue]", "[blue]Running: {command}[/blue]": "[blue]Running: {command}[/blue]", "[bold green]Share link:[/bold green]": "[bold green]Share link:[/bold green]", "[bold]Aliases ({count}):[/bold]\n": "[bold]Aliases ({count}):[/bold]\n", "[bold]Allowlist ({count} peers):[/bold]\n": "[bold]Allowlist ({count} peers):[/bold]\n", "[bold]Configuration:[/bold]": "[bold]Configuration:[/bold]", "[bold]Discovering NAT devices...[/bold]\n": "[bold]Discovering NAT devices...[/bold]\n", "[bold]Mapping {protocol} port {port}...[/bold]": "[bold]Mapping {protocol} port {port}...[/bold]", "[bold]NAT Traversal Status[/bold]\n": "[bold]NAT Traversal Status[/bold]\n", "[bold]Removing {protocol} port mapping for port {port}...[/bold]": "[bold]Removing {protocol} port mapping for port {port}...[/bold]", "[bold]Sync Mode for: {path}[/bold]\n": "[bold]Sync Mode for: {path}[/bold]\n", "[bold]Sync Status for: {path}[/bold]\n": "[bold]Sync Status for: {path}[/bold]\n", "[bold]Xet Cache Information[/bold]\n": "[bold]Xet Cache Information[/bold]\n", "[bold]Xet Deduplication Cache Statistics[/bold]\n": "[bold]Xet Deduplication Cache Statistics[/bold]\n", "[bold]Xet Protocol Status[/bold]\n": "[bold]Xet Protocol Status[/bold]\n", "[cyan]Checking for existing daemon instance...[/cyan]": "[cyan]Checking for existing daemon instance...[/cyan]", "[cyan]Creating {format} torrent...[/cyan]": "[cyan]Creating {format} torrent...[/cyan]", "[cyan]Download:[/cyan] {rate:.2f} KiB/s": "[cyan]Download:[/cyan] {rate:.2f} KiB/s", "[cyan]Initializing configuration...[/cyan]": "[cyan]Initializing configuration...[/cyan]", "[cyan]Loading filter from: {file_path}[/cyan]": "[cyan]Loading filter from: {file_path}[/cyan]", "[cyan]Restarting daemon...[/cyan]": "[cyan]Restarting daemon...[/cyan]", "[cyan]Running diagnostic checks...[/cyan]\n": "[cyan]Running diagnostic checks...[/cyan]\n", "[cyan]Starting daemon in background...[/cyan]": "[cyan]Starting daemon in background...[/cyan]", "[cyan]Starting daemon in foreground mode...[/cyan]": "[cyan]Starting daemon in foreground mode...[/cyan]", "[cyan]Testing proxy connection to {host}:{port}...[/cyan]": "[cyan]Testing proxy connection to {host}:{port}...[/cyan]", "[cyan]Torrents:[/cyan] {num_torrents}": "[cyan]Torrenteak:[/cyan] {num_torrents}", "[cyan]Updating filter lists from {count} URL(s)...[/cyan]": "[cyan]Updating filter lists from {count} URL(s)...[/cyan]", "[cyan]Upload:[/cyan] {rate:.2f} KiB/s": "[cyan]Upload:[/cyan] {rate:.2f} KiB/s", "[cyan]Uptime:[/cyan] {uptime:.1f}s": "[cyan]Uptime:[/cyan] {uptime:.1f}s", "[cyan]Using custom IPC port: {port}[/cyan]": "[cyan]Using custom IPC port: {port}[/cyan]", "[cyan]Waiting for daemon to be ready...[/cyan]": "[cyan]Waiting for daemon to be ready...[/cyan]", "[dim] uv run btbt daemon start --foreground[/dim]": "[dim] uv run btbt daemon start --lehen planoa[/dim]", "[dim]Daemon may still be starting. Use 'btbt daemon status' to check.[/dim]": "[dim]Daemon may still be starting. Use 'btbt daemon status' to check.[/dim]", "[dim]Info hash v1 (SHA-1): {hash}...[/dim]": "[dim]Informazio hash v1 (SHA-1): {hash}...[/dim]", "[dim]Info hash v2 (SHA-256): {hash}...[/dim]": "[dim]Informazio hash v2 (SHA-256): {hash}...[/dim]", "[dim]No active port mappings[/dim]": "[dim]No active port mappings[/dim]", "[dim]Output: {path}[/dim]": "[dim]Output: {path}[/dim]", "[dim]Please restart manually: 'btbt daemon restart'[/dim]": "[dim]Please restart manually: 'btbt daemon restart'[/dim]", "[dim]Please restart the daemon manually: 'btbt daemon restart'[/dim]": "[dim]Please restart the daemon manually: 'btbt daemon restart'[/dim]", "[dim]Protocol: {method}[/dim]": "[dim]Protocol: {method}[/dim]", "[dim]See daemon log: {path}[/dim]": "[dim]See daemon log: {path}[/dim]", "[dim]Source: {path}[/dim]": "[dim]Source: {path}[/dim]", "[dim]Trackers: {count}[/dim]": "[dim]Trackers: {count}[/dim]", "[dim]Try running with --foreground flag to see detailed error output:[/dim]": "[dim]Try running with --foreground flag to see detailed error output:[/dim]", "[dim]Use 'btbt daemon status' to check daemon status[/dim]": "[dim]Use 'btbt daemon status' to check daemon status[/dim]", "[dim]Use -v flag for more details or check daemon logs[/dim]": "[dim]Use -v flag for more details or check daemon logs[/dim]", "[dim]Web seeds: {count}[/dim]": "[dim]Web-haziak: {count}[/dim]", "[green]ALLOWED[/green]": "[green]ALLOWED[/green]", "[green]Active Protocol:[/green] {method}": "[green]Active Protocol:[/green] {method}", "[green]Added alert rule {name}[/green]": "[green]Added alert rule {name}[/green]", "[green]Added to IPFS:[/green] {cid}": "[green]Added to IPFS:[/green] {cid}", "[green]Applying {preset} optimizations...[/green]": "[green]Applying {preset} optimizations...[/green]", "[green]Benchmark results:[/green] {results}": "[green]Benchmark results:[/green] {results}", "[green]CA certificates path set to {path}. Configuration saved to {config_file}[/green]": "[green]CA certificates path set to {path}. Configuration saved to {config_file}[/green]", "[green]Checkpoint for {hash} is valid[/green]": "[green]Checkpoint for {hash} is valid[/green]", "[green]Checkpoint for {info_hash} is valid[/green]": "[green]Checkpoint for {info_hash} is valid[/green]", "[green]Checkpoint refreshed for {hash}[/green]": "[green]Checkpoint refreshed for {hash}[/green]", "[green]Checkpoint reloaded for {hash}[/green]": "[green]Checkpoint reloaded for {hash}[/green]", "[green]Checkpoint saved for torrent[/green]": "[green]Checkpoint saved for torrent[/green]", "[green]Checkpoint saved[/green]": "[green]Checkpoint saved[/green]", "[green]Checkpoint valid[/green]": "[green]Checkpoint valid[/green]", "[green]Cleared all active alerts[/green]": "[green]Cleared all active alerts[/green]", "[green]Cleared queue[/green]": "[green]Cleared queue[/green]", "[green]Client certificate set. Configuration saved to {config_file}[/green]": "[green]Client certificate set. Configuration saved to {config_file}[/green]", "[green]Connected to daemon[/green]": "[green]Connected to daemon[/green]", "[green]Content pinned[/green]": "[green]Content pinned[/green]", "[green]Content saved to:[/green] {output}": "[green]Content saved to:[/green] {output}", "[green]DHT aggressive mode {mode} for torrent: {info_hash}[/green]": "[green]DHT aggressive mode {mode} for torrent: {info_hash}[/green]", "[green]Daemon is running[/green] (PID: {pid})": "[green]Daemon is running[/green] (PID: {pid})", "[green]Daemon restarted successfully[/green]": "[green]Daemon restarted successfully[/green]", "[green]Daemon stopped gracefully[/green]": "[green]Daemon stopped gracefully[/green]", "[green]Daemon stopped[/green]": "[green]Daemon stopped[/green]", "[green]Deleted checkpoint for {hash}[/green]": "[green]Deleted checkpoint for {hash}[/green]", "[green]Deleted checkpoint for {info_hash}[/green]": "[green]Deleted checkpoint for {info_hash}[/green]", "[green]Deselected all files.[/green]": "[green]Deselected all files.[/green]", "[green]Deselected all files[/green]": "[green]Deselected all files[/green]", "[green]Deselected {count} file(s)[/green]": "[green]Deselected {count} file(s)[/green]", "[green]External IP:[/green] {ip}": "[green]External IP:[/green] {ip}", "[green]Force started {count} torrent(s)[/green]": "[green]Force started {count} torrent(s)[/green]", "[green]Found checkpoint for: {torrent_name}[/green]": "[green]Found checkpoint for: {torrent_name}[/green]", "[green]Integrity verification passed: {count} pieces verified[/green]": "[green]Integrity verification passed: {count} pieces verified[/green]", "[green]Loaded alert rules from {path}[/green]": "[green]Loaded alert rules from {path}[/green]", "[green]Loaded {count} alert rules from {path}[/green]": "[green]Loaded {count} alert rules from {path}[/green]", "[green]Locale set to: {locale_code}[/green]": "[green]Locale set to: {locale_code}[/green]", "[green]Magnet link added to daemon: {info_hash}[/green]": "[green]Magnet link added to daemon: {info_hash}[/green]", "[green]Moved to position {position}[/green]": "[green]Moved to position {position}[/green]", "[green]Network configuration looks optimal![/green]": "[green]Network configuration looks optimal![/green]", "[green]No checkpoints older than {days} days found[/green]": "[green]No checkpoints older than {days} days found[/green]", "[green]Optimizations applied successfully![/green]\n[yellow]Note: Some changes may require restart to take effect.[/yellow]": "[green]Optimizations applied successfully![/green]\n[yellow]Note: Some changes may require restart to take effect.[/yellow]", "[green]Optimizations saved to {path}[/green]": "[green]Optimizations saved to {path}[/green]", "[green]PEX refreshed for torrent: {info_hash}[/green]": "[green]PEX refreshed for torrent: {info_hash}[/green]", "[green]Paused torrent[/green]": "[green]Paused torrent[/green]", "[green]Paused {count} torrent(s)[/green]": "[green]Paused {count} torrent(s)[/green]", "[green]Peer validation hooks are enabled by configuration[/green]": "[green]Peer validation hooks are enabled by configuration[/green]", "[green]Per-peer rate limit for {peer_key}: {limit}[/green]": "[green]Per-peer rate limit for {peer_key}: {limit}[/green]", "[green]Per-peer rate limit set: {peer_key} = {upload} KiB/s[/green]": "[green]Per-peer rate limit set: {peer_key} = {upload} KiB/s[/green]", "[green]Performing basic configuration scan...[/green]": "[green]Performing basic configuration scan...[/green]", "[green]Pinned:[/green] {cid}": "[green]Pinned:[/green] {cid}", "[green]Proxy configuration saved to {config_file}[/green]": "[green]Proxy configuration saved to {config_file}[/green]", "[green]Proxy configuration updated successfully[/green]": "[green]Proxy configuration updated successfully[/green]", "[green]Proxy has been disabled[/green]": "[green]Proxy has been disabled[/green]", "[green]Removed alert rule {name}[/green]": "[green]Removed alert rule {name}[/green]", "[green]Removed torrent from queue[/green]": "[green]Removed torrent from queue[/green]", "[green]Reset all options for torrent {hash}[/green]": "[green]Reset all options for torrent {hash}[/green]", "[green]Reset {key} for torrent {hash}[/green]": "[green]Reset {key} for torrent {hash}[/green]", "[green]Restored checkpoint for: {name}[/green]\nInfo hash: {hash}": "[green]Restored checkpoint for: {name}[/green]\nInfo hash: {hash}", "[green]Resume data structure is valid[/green]": "[green]Resume data structure is valid[/green]", "[green]Resumed torrent[/green]": "[green]Resumed torrent[/green]", "[green]Resumed {count} torrent(s)[/green]": "[green]Resumed {count} torrent(s)[/green]", "[green]Resuming from checkpoint[/green]": "[green]Resuming from checkpoint[/green]", "[green]SSL certificate verification enabled. Configuration saved to {config_file}[/green]": "[green]SSL certificate verification enabled. Configuration saved to {config_file}[/green]", "[green]SSL for peers disabled. Configuration saved to {config_file}[/green]": "[green]SSL for peers disabled. Configuration saved to {config_file}[/green]", "[green]SSL for peers enabled (experimental). Configuration saved to {config_file}[/green]": "[green]SSL for peers enabled (experimental). Configuration saved to {config_file}[/green]", "[green]SSL for trackers disabled. Configuration saved to {config_file}[/green]": "[green]SSL for trackers disabled. Configuration saved to {config_file}[/green]", "[green]SSL for trackers enabled. Configuration saved to {config_file}[/green]": "[green]SSL for trackers enabled. Configuration saved to {config_file}[/green]", "[green]Saved alert rules to {path}[/green]": "[green]Saved alert rules to {path}[/green]", "[green]Saved resume data for {hash}[/green]": "[green]Saved resume data for {hash}[/green]", "[green]Selected all files[/green]": "[green]Selected all files[/green]", "[green]Selected {count} file(s).[/green]": "[green]Selected {count} file(s).[/green]", "[green]Selected {count} file(s)[/green]": "[green]Selected {count} file(s)[/green]", "[green]Set file {index} priority to {priority}[/green]": "[green]Set file {index} priority to {priority}[/green]", "[green]Set priority to {priority}[/green]": "[green]Set priority to {priority}[/green]", "[green]Set rate limit for {count} peers: {upload} KiB/s[/green]": "[green]Set rate limit for {count} peers: {upload} KiB/s[/green]", "[green]Set {key} = {value} for torrent {hash}[/green]": "[green]Set {key} = {value} for torrent {hash}[/green]", "[green]Successfully resumed download: {hash}[/green]": "[green]Successfully resumed download: {hash}[/green]", "[green]Successfully resumed download: {resumed_info_hash}[/green]": "[green]Successfully resumed download: {resumed_info_hash}[/green]", "[green]TLS protocol version set to {version}. Configuration saved to {config_file}[/green]": "[green]TLS protocol version set to {version}. Configuration saved to {config_file}[/green]", "[green]Tested rule {name} with value {value}[/green]": "[green]Tested rule {name} with value {value}[/green]", "[green]Torrent added to daemon: {info_hash}[/green]": "[green]Torrent added to daemon: {info_hash}[/green]", "[green]Torrent cancelled: {info_hash}{checkpoint_info}[/green]": "[green]Torrent cancelled: {info_hash}{checkpoint_info}[/green]", "[green]Torrent force started: {info_hash}[/green]": "[green]Torrent force started: {info_hash}[/green]", "[green]Torrent paused: {info_hash}{checkpoint_info}[/green]": "[green]Torrent paused: {info_hash}{checkpoint_info}[/green]", "[green]Torrent resumed: {info_hash}{checkpoint_info}[/green]": "[green]Torrent resumed: {info_hash}{checkpoint_info}[/green]", "[green]Tracker added: {url} to torrent {info_hash}[/green]": "[green]Tracker added: {url} to torrent {info_hash}[/green]", "[green]Tracker removed: {url} from torrent {info_hash}[/green]": "[green]Tracker removed: {url} from torrent {info_hash}[/green]", "[green]Unpinned:[/green] {cid}": "[green]Unpinned:[/green] {cid}", "[green]Updated {key} to {value}[/green]": "[green]Updated {key} to {value}[/green]", "[green]Wrote metrics to {path}[/green]": "[green]Wrote metrics to {path}[/green]", "[green]{message}: {config_file}[/green]": "[green]{message}: {config_file}[/green]", "[green]✓ Port mapping removed[/green]": "[green]✓ Port mapping removed[/green]", "[green]✓ Port mapping successful![/green]": "[green]✓ Port mapping successful![/green]", "[green]✓ Port mappings refreshed[/green]": "[green]✓ Port mappings refreshed[/green]", "[green]✓ Proxy connection test successful[/green]": "[green]✓ Proxy connection test successful[/green]", "[green]✓ Torrent created successfully: {path}[/green]": "[green]✓ Torrent created successfully: {path}[/green]", "[green]✓[/green] Added filter rule: {ip_range} ({mode})": "[green]✓[/green] Added filter rule: {ip_range} ({mode})", "[green]✓[/green] Added peer {peer_id} to allowlist": "[green]✓[/green] Added peer {peer_id} to allowlist", "[green]✓[/green] Added peer {peer_id} to allowlist with alias '{alias}'": "[green]✓[/green] Added peer {peer_id} to allowlist with alias '{alias}'", "[green]✓[/green] Cleaned {cleaned} unused chunks": "[green]✓[/green] Cleaned {cleaned} unused chunks", "[green]✓[/green] Configuration saved to {file}": "[green]✓[/green] Configuration saved to {file}", "[green]✓[/green] Daemon process started (PID {pid})": "[green]✓[/green] Daemon process started (PID {pid})", "[green]✓[/green] Daemon started successfully (PID {pid}, took {elapsed:.1f}s)": "[green]✓[/green] Daemon started successfully (PID {pid}, took {elapsed:.1f}s)", "[green]✓[/green] Folder sync started": "[green]✓[/green] Folder sync started", "[green]✓[/green] Generated .tonic file: {file}": "[green]✓[/green] Generated .tonic file: {file}", "[green]✓[/green] Generated new API key for daemon": "[green]✓[/green] Generated new API key for daemon", "[green]✓[/green] Generated tonic?: link:": "[green]✓[/green] Generated tonic?: link:", "[green]✓[/green] Loaded {loaded} rules from {file_path}": "[green]✓[/green] Loaded {loaded} rules from {file_path}", "[green]✓[/green] Loaded {total_loaded} total rules": "[green]✓[/green] Loaded {total_loaded} total rules", "[green]✓[/green] Removed alias for peer {peer_id}": "[green]✓[/green] Removed alias for peer {peer_id}", "[green]✓[/green] Removed filter rule: {ip_range}": "[green]✓[/green] Removed filter rule: {ip_range}", "[green]✓[/green] Removed peer {peer_id} from allowlist": "[green]✓[/green] Removed peer {peer_id} from allowlist", "[green]✓[/green] Set alias '{alias}' for peer {peer_id}": "[green]✓[/green] Set alias '{alias}' for peer {peer_id}", "[green]✓[/green] Set {key} = {value}": "[green]✓[/green] Set {key} = {value}", "[green]✓[/green] Successfully updated {count} filter list(s)": "[green]✓[/green] Successfully updated {count} filter list(s)", "[green]✓[/green] Sync mode updated": "[green]✓[/green] Sync mode updated", "[green]✓[/green] Tonic link:": "[green]✓[/green] Tonic link:", "[green]✓[/green] Updated config file: {file}": "[green]✓[/green] Updated config file: {file}", "[green]✓[/green] Xet protocol enabled": "[green]✓[/green] Xet protocol enabled", "[green]✓[/green] uTP configuration reset to defaults": "[green]✓[/green] uTP configuration reset to defaults", "[green]✓[/green] uTP transport enabled": "[green]✓[/green] uTP transport enabled", "[red]--name is required to remove a rule[/red]": "[red]--name is required to remove a rule[/red]", "[red]--name is required to test a rule[/red]": "[red]--name is required to test a rule[/red]", "[red]--name, --metric and --condition are required to add a rule[/red]": "[red]--name, --metric and --condition are required to add a rule[/red]", "[red]--value is required with --test[/red]": "[red]--value is required with --test[/red]", "[red]BLOCKED[/red]": "[red]BLOKEATUA[/red]", "[red]Certificate file does not exist: {path}[/red]": "[red]Certificate file does not exist: {path}[/red]", "[red]Certificate path must be a file: {path}[/red]": "[red]Certificate path must be a file: {path}[/red]", "[red]Configuration key not found: {key}[/red]": "[red]Configuration key not found: {key}[/red]", "[red]Content not found: {cid}[/red]": "[red]Content not found: {cid}[/red]", "[red]Daemon is not running[/red]": "[red]Daemon is not running[/red]", "[red]Daemon process crashed[/red]": "[red]Daemon process crashed[/red]", "[red]Dashboard error: {e}[/red]": "[red]Dashboard error: {e}[/red]", "[red]Directories not yet supported[/red]": "[red]Directories not yet supported[/red]", "[red]Error adding content: {e}[/red]": "[red]Error adding content: {e}[/red]", "[red]Error adding peer to allowlist: {e}[/red]": "[red]Error adding peer to allowlist: {e}[/red]", "[red]Error disabling SSL for peers: {e}[/red]": "[red]Error disabling SSL for peers: {e}[/red]", "[red]Error disabling SSL for trackers: {e}[/red]": "[red]Error disabling SSL for trackers: {e}[/red]", "[red]Error disabling Xet protocol: {e}[/red]": "[red]Error disabling Xet protocol: {e}[/red]", "[red]Error disabling certificate verification: {e}[/red]": "[red]Error disabling certificate verification: {e}[/red]", "[red]Error during cleanup: {e}[/red]": "[red]Error during cleanup: {e}[/red]", "[red]Error enabling SSL for peers: {e}[/red]": "[red]Error enabling SSL for peers: {e}[/red]", "[red]Error enabling SSL for trackers: {e}[/red]": "[red]Error enabling SSL for trackers: {e}[/red]", "[red]Error enabling Xet protocol: {e}[/red]": "[red]Error enabling Xet protocol: {e}[/red]", "[red]Error enabling certificate verification: {e}[/red]": "[red]Error enabling certificate verification: {e}[/red]", "[red]Error ensuring daemon is running: {e}[/red]": "[red]Error ensuring daemon is running: {e}[/red]", "[red]Error generating .tonic file: {e}[/red]": "[red]Error generating .tonic file: {e}[/red]", "[red]Error generating tonic link: {e}[/red]": "[red]Error generating tonic link: {e}[/red]", "[red]Error getting SSL status: {e}[/red]": "[red]Error getting SSL status: {e}[/red]", "[red]Error getting Xet status: {e}[/red]": "[red]Error getting Xet status: {e}[/red]", "[red]Error getting content: {e}[/red]": "[red]Error getting content: {e}[/red]", "[red]Error getting peers: {e}[/red]": "[red]Error getting peers: {e}[/red]", "[red]Error getting stats: {e}[/red]": "[red]Error getting stats: {e}[/red]", "[red]Error getting status: {e}[/red]": "[red]Error getting status: {e}[/red]", "[red]Error getting sync mode: {e}[/red]": "[red]Error getting sync mode: {e}[/red]", "[red]Error listing aliases: {e}[/red]": "[red]Error listing aliases: {e}[/red]", "[red]Error listing allowlist: {e}[/red]": "[red]Error listing allowlist: {e}[/red]", "[red]Error pinning content: {e}[/red]": "[red]Error pinning content: {e}[/red]", "[red]Error reading authenticated swarm status: {e}[/red]": "[red]Error reading authenticated swarm status: {e}[/red]", "[red]Error removing alias: {e}[/red]": "[red]Error removing alias: {e}[/red]", "[red]Error removing peer from allowlist: {e}[/red]": "[red]Error removing peer from allowlist: {e}[/red]", "[red]Error restarting daemon: {e}[/red]": "[red]Error restarting daemon: {e}[/red]", "[red]Error retrieving cache info: {e}[/red]": "[red]Error retrieving cache info: {e}[/red]", "[red]Error retrieving disk statistics: {error}[/red]": "[red]Error retrieving disk statistics: {error}[/red]", "[red]Error retrieving network statistics: {error}[/red]": "[red]Error retrieving network statistics: {error}[/red]", "[red]Error retrieving stats: {e}[/red]": "[red]Error retrieving stats: {e}[/red]", "[red]Error setting CA certificates path: {e}[/red]": "[red]Error setting CA certificates path: {e}[/red]", "[red]Error setting alias: {e}[/red]": "[red]Error setting alias: {e}[/red]", "[red]Error setting client certificate: {e}[/red]": "[red]Error setting client certificate: {e}[/red]", "[red]Error setting protocol version: {e}[/red]": "[red]Error setting protocol version: {e}[/red]", "[red]Error setting sync mode: {e}[/red]": "[red]Error setting sync mode: {e}[/red]", "[red]Error starting sync: {e}[/red]": "[red]Error starting sync: {e}[/red]", "[red]Error unpinning content: {e}[/red]": "[red]Error unpinning content: {e}[/red]", "[red]Error updating authenticated swarm mode: {e}[/red]": "[red]Error updating authenticated swarm mode: {e}[/red]", "[red]Error updating configuration: {error}[/red]": "[red]Error updating configuration: {error}[/red]", "[red]Error updating discovery mode: {e}[/red]": "[red]Error updating discovery mode: {e}[/red]", "[red]Error updating parse-policy behavior: {e}[/red]": "[red]Error updating parse-policy behavior: {e}[/red]", "[red]Error updating strict discovery mode: {e}[/red]": "[red]Error updating strict discovery mode: {e}[/red]", "[red]Error updating trusted IDs: {e}[/red]": "[red]Error updating trusted IDs: {e}[/red]", "[red]Error: Cannot specify both --hybrid and --v1[/red]": "[red]Error: Cannot specify both --hybrid and --v1[/red]", "[red]Error: Cannot specify both --v2 and --hybrid[/red]": "[red]Error: Cannot specify both --v2 and --hybrid[/red]", "[red]Error: Cannot specify both --v2 and --v1[/red]": "[red]Error: Cannot specify both --v2 and --v1[/red]", "[red]Error: Configuration not available[/red]": "[red]Error: Configuration not available[/red]", "[red]Error: Failed to get daemon status: {error}[/red]": "[red]Error: Failed to get daemon status: {error}[/red]", "[red]Error: Info hash must be 40 hex characters[/red]": "[red]Error: Info hash must be 40 hex characters[/red]", "[red]Error: Invalid torrent file: {torrent_file}[/red]": "[red]Error: Invalid torrent file: {torrent_file}[/red]", "[red]Error: Network configuration not available[/red]": "[red]Error: Network configuration not available[/red]", "[red]Error: Piece length must be a power of 2[/red]": "[red]Error: Piece length must be a power of 2[/red]", "[red]Error: Piece length must be at least 16 KiB (16384 bytes)[/red]": "[red]Error: Piece length must be at least 16 KiB (16384 bytes)[/red]", "[red]Error: Source directory is empty[/red]": "[red]Error: Source directory is empty[/red]", "[red]Error: Source path does not exist: {path}[/red]": "[red]Error: Source path does not exist: {path}[/red]", "[red]Error: {e}[/red]": "[red]Errorea: {e}[/red]", "[red]Error:[/red] Invalid value for {key}: {value}": "[red]Error:[/red] Invalid value for {key}: {value}", "[red]Error:[/red] Unknown configuration key: {key}": "[red]Error:[/red] Unknown configuration key: {key}", "[red]Export not available in daemon mode[/red]": "[red]Export not available in daemon mode[/red]", "[red]Failed to add magnet: {error}[/red]": "[red]Failed to add magnet: {error}[/red]", "[red]Failed to cancel: {error}[/red]": "[red]Failed to cancel: {error}[/red]", "[red]Failed to clear active alerts: {e}[/red]": "[red]Failed to clear active alerts: {e}[/red]", "[red]Failed to create session[/red]": "[red]Failed to create session[/red]", "[red]Failed to disable proxy: {e}[/red]": "[red]Failed to disable proxy: {e}[/red]", "[red]Failed to force start: {error}[/red]": "[red]Failed to force start: {error}[/red]", "[red]Failed to get proxy status: {e}[/red]": "[red]Failed to get proxy status: {e}[/red]", "[red]Failed to load alert rules: {e}[/red]": "[red]Failed to load alert rules: {e}[/red]", "[red]Failed to load rules: {e}[/red]": "[red]Failed to load rules: {e}[/red]", "[red]Failed to pause: {error}[/red]": "[red]Failed to pause: {error}[/red]", "[red]Failed to reset options[/red]": "[red]Failed to reset options[/red]", "[red]Failed to restart daemon[/red]": "[red]Failed to restart daemon[/red]", "[red]Failed to resume: {error}[/red]": "[red]Failed to resume: {error}[/red]", "[red]Failed to run tests: {e}[/red]": "[red]Failed to run tests: {e}[/red]", "[red]Failed to save rules: {e}[/red]": "[red]Failed to save rules: {e}[/red]", "[red]Failed to set option[/red]": "[red]Failed to set option[/red]", "[red]Failed to set proxy configuration: {e}[/red]": "[red]Failed to set proxy configuration: {e}[/red]", "[red]Failed to start daemon. Cannot proceed without daemon.[/red]\n[yellow]Please check:[/yellow]\n 1. Daemon logs for startup errors\n 2. Port conflicts (check if port is already in use)\n 3. Permissions (ensure you have permission to start daemon)\n\n[cyan]To start daemon manually: 'btbt daemon start'[/cyan]": "[red]Failed to start daemon. Cannot proceed without daemon.[/red]\n[yellow]Please check:[/yellow]\n 1. Daemon logs for startup errors\n 2. Port conflicts (check if port is already in use)\n 3. Permissions (ensure you have permission to start daemon)\n\n[cyan]To start daemon manually: 'btbt daemon start'[/cyan]", "[red]Failed to stop: {error}[/red]": "[red]Failed to stop: {error}[/red]", "[red]Failed to test proxy: {e}[/red]": "[red]Failed to test proxy: {e}[/red]", "[red]Failed to test rule: {e}[/red]": "[red]Failed to test rule: {e}[/red]", "[red]Failed: {error}[/red]": "[red]Failed: {error}[/red]", "[red]File not found: {e}[/red]": "[red]File not found: {e}[/red]", "[red]IP filter not initialized. Please enable it in configuration.[/red]": "[red]IP filter not initialized. Please enable it in configuration.[/red]", "[red]IP filter not initialized.[/red]": "[red]IP filter not initialized.[/red]", "[red]IPFS protocol not available[/red]": "[red]IPFS protocol not available[/red]", "[red]Import not available in daemon mode[/red]": "[red]Import not available in daemon mode[/red]", "[red]Invalid IP address: {ip}[/red]": "[red]Invalid IP address: {ip}[/red]", "[red]Invalid info hash format[/red]": "[red]Invalid info hash format[/red]", "[red]Invalid info hash: {hash}[/red]": "[red]Invalid info hash: {hash}[/red]", "[red]Invalid magnet link: {e}[/red]": "[red]Invalid magnet link: {e}[/red]", "[red]Invalid public key: {e}[/red]": "[red]Invalid public key: {e}[/red]", "[red]Invalid value for {key}: {error}[/red]": "[red]Invalid value for {key}: {error}[/red]", "[red]Key file does not exist: {path}[/red]": "[red]Key file does not exist: {path}[/red]", "[red]Key path must be a file: {path}[/red]": "[red]Key path must be a file: {path}[/red]", "[red]Metrics error: {e}[/red]": "[red]Metrics error: {e}[/red]", "[red]No stats found for CID: {cid}[/red]": "[red]No stats found for CID: {cid}[/red]", "[red]Path does not exist: {path}[/red]": "[red]Path does not exist: {path}[/red]", "[red]Path must be a file or directory: {path}[/red]": "[red]Path must be a file or directory: {path}[/red]", "[red]Peer {peer_id} not found in allowlist[/red]": "[red]Peer {peer_id} not found in allowlist[/red]", "[red]Proxy error: {e}[/red]": "[red]Proxy error: {e}[/red]", "[red]Proxy host and port must be configured[/red]": "[red]Proxy host and port must be configured[/red]", "[red]Rule not found: {name}[/red]": "[red]Rule not found: {name}[/red]", "[red]Specify CID or use --all[/red]": "[red]Specify CID or use --all[/red]", "[red]Torrent not found: {hash}[/red]": "[red]Torrent not found: {hash}[/red]", "[red]Unexpected error during resume: {e}[/red]": "[red]Unexpected error during resume: {e}[/red]", "[red]Unknown configuration key: {key}[/red]": "[red]Unknown configuration key: {key}[/red]", "[red]Validation error: {e}[/red]": "[red]Validation error: {e}[/red]", "[red]{msg}[/red]": "[red]{msg}[/red]", "[red]✗ Failed to remove port mapping[/red]": "[red]✗ Failed to remove port mapping[/red]", "[red]✗ Port mapping failed[/red]": "[red]✗ Port mapping failed[/red]", "[red]✗ Proxy connection test failed[/red]": "[red]✗ Proxy connection test failed[/red]", "[red]✗[/red] Daemon is already running with PID {pid}": "[red]✗[/red] Daemon is already running with PID {pid}", "[red]✗[/red] Daemon process (PID {pid}) crashed during startup (after {elapsed:.1f}s)": "[red]✗[/red] Daemon process (PID {pid}) crashed during startup (after {elapsed:.1f}s)", "[red]✗[/red] Daemon process (PID {pid}) exited immediately after starting": "[red]✗[/red] Daemon process (PID {pid}) exited immediately after starting", "[red]✗[/red] Failed to add filter rule: {ip_range}": "[red]✗[/red] Failed to add filter rule: {ip_range}", "[red]✗[/red] Failed to load rules from {file_path}": "[red]✗[/red] Failed to load rules from {file_path}", "[red]✗[/red] Failed to start daemon: {e}": "[red]✗[/red] Failed to start daemon: {e}", "[red]✗[/red] Failed to update filter lists": "[red]✗[/red] Failed to update filter lists", "[yellow]1. Network Connectivity[/yellow]": "[yellow]1. Network Connectivity[/yellow]", "[yellow]API key not found in config, cannot get detailed status[/yellow]": "[yellow]API key not found in config, cannot get detailed status[/yellow]", "[yellow]Active Protocol:[/yellow] None (not discovered)": "[yellow]Active Protocol:[/yellow] None (not discovered)", "[yellow]Allowlist is empty[/yellow]": "[yellow]Allowlist is empty[/yellow]", "[yellow]Authenticated swarm setting updated (configuration not persisted - no config file)[/yellow]": "[yellow]Authenticated swarm setting updated (configuration not persisted - no config file)[/yellow]", "[yellow]Authenticated swarm setting updated (test mode, write skipped)[/yellow]": "[yellow]Authenticated swarm setting updated (test mode, write skipped)[/yellow]", "[yellow]Authenticated swarms not configured[/yellow]": "[yellow]Authenticated swarms not configured[/yellow]", "[yellow]Automatic repair not implemented[/yellow]": "[yellow]Automatic repair not implemented[/yellow]", "[yellow]CA certificates path set to {path} (configuration not persisted - no config file)[/yellow]": "[yellow]CA certificates path set to {path} (configuration not persisted - no config file)[/yellow]", "[yellow]CA certificates path set to {path} (skipped write in test mode)[/yellow]": "[yellow]CA certificates path set to {path} (skipped write in test mode)[/yellow]", "[yellow]Checkpoint cannot be auto-resumed - no torrent source found[/yellow]": "[yellow]Checkpoint cannot be auto-resumed - no torrent source found[/yellow]", "[yellow]Checkpoint for {hash} is missing or invalid[/yellow]": "[yellow]Checkpoint for {hash} is missing or invalid[/yellow]", "[yellow]Checkpoint missing/invalid[/yellow]": "[yellow]Checkpoint missing/invalid[/yellow]", "[yellow]Client certificate set (configuration not persisted - no config file)[/yellow]": "[yellow]Client certificate set (configuration not persisted - no config file)[/yellow]", "[yellow]Client certificate set (skipped write in test mode)[/yellow]": "[yellow]Client certificate set (skipped write in test mode)[/yellow]", "[yellow]Configuration changes require daemon restart.[/yellow]": "[yellow]Configuration changes require daemon restart.[/yellow]", "[yellow]Could not deselect: {error}[/yellow]": "[yellow]Could not deselect: {error}[/yellow]", "[yellow]Could not get detailed status via IPC[/yellow]": "[yellow]Could not get detailed status via IPC[/yellow]", "[yellow]Could not save to config file: {error}[/yellow]": "[yellow]Could not save to config file: {error}[/yellow]", "[yellow]Disk I/O manager not running. Statistics unavailable.[/yellow]": "[yellow]Disk I/O manager not running. Statistics unavailable.[/yellow]", "[yellow]Dry run: Would clean chunks older than {days} days[/yellow]": "[yellow]Dry run: Would clean chunks older than {days} days[/yellow]", "[yellow]External IP not available[/yellow]": "[yellow]External IP not available[/yellow]", "[yellow]External IP:[/yellow] Not available": "[yellow]External IP:[/yellow] Not available", "[yellow]Failed to generate tonic link[/yellow]": "[yellow]Failed to generate tonic link[/yellow]", "[yellow]Failed to move torrent[/yellow]": "[yellow]Failed to move torrent[/yellow]", "[yellow]Failed to refresh checkpoint for {hash}[/yellow]": "[yellow]Failed to refresh checkpoint for {hash}[/yellow]", "[yellow]Failed to reload checkpoint for {hash}[/yellow]": "[yellow]Failed to reload checkpoint for {hash}[/yellow]", "[yellow]Fast resume is disabled[/yellow]": "[yellow]Fast resume is disabled[/yellow]", "[yellow]Found checkpoint for: {name}[/yellow]": "[yellow]Found checkpoint for: {name}[/yellow]", "[yellow]Found checkpoint for: {torrent_name}[/yellow]": "[yellow]Found checkpoint for: {torrent_name}[/yellow]", "[yellow]Full rehash not implemented in CLI; use resume to trigger piece verification[/yellow]": "[yellow]Full rehash not implemented in CLI; use resume to trigger piece verification[/yellow]", "[yellow]IP filter not initialized or disabled.[/yellow]": "[yellow]IP filter not initialized or disabled.[/yellow]", "[yellow]Integrity verification failed: {count} pieces failed[/yellow]": "[yellow]Integrity verification failed: {count} pieces failed[/yellow]", "[yellow]NAT Status[/yellow]": "[yellow]NAT Status[/yellow]", "[yellow]Network optimizer not available[/yellow]": "[yellow]Network optimizer not available[/yellow]", "[yellow]Network statistics not available[/yellow]": "[yellow]Network statistics not available[/yellow]", "[yellow]No active alerts[/yellow]": "[yellow]No active alerts[/yellow]", "[yellow]No alert rules defined[/yellow]": "[yellow]No alert rules defined[/yellow]", "[yellow]No alias found for peer {peer_id}[/yellow]": "[yellow]No alias found for peer {peer_id}[/yellow]", "[yellow]No aliases found in allowlist[/yellow]": "[yellow]No aliases found in allowlist[/yellow]", "[yellow]No authenticated swarms configuration found[/yellow]": "[yellow]No authenticated swarms configuration found[/yellow]", "[yellow]No cached scrape results[/yellow]": "[yellow]No cached scrape results[/yellow]", "[yellow]No checkpoint found for {hash}[/yellow]": "[yellow]No checkpoint found for {hash}[/yellow]", "[yellow]No checkpoint found for {info_hash}[/yellow]": "[yellow]No checkpoint found for {info_hash}[/yellow]", "[yellow]No chunks in cache[/yellow]": "[yellow]No chunks in cache[/yellow]", "[yellow]No config file found - configuration not persisted[/yellow]": "[yellow]No config file found - configuration not persisted[/yellow]", "[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]": "[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]", "[yellow]No filter URLs configured.[/yellow]": "[yellow]No filter URLs configured.[/yellow]", "[yellow]No filter rules configured.[/yellow]": "[yellow]No filter rules configured.[/yellow]", "[yellow]No optimizations were applied (already optimal or unsupported)[/yellow]": "[yellow]No optimizations were applied (already optimal or unsupported)[/yellow]", "[yellow]No performance action specified[/yellow]": "[yellow]No performance action specified[/yellow]", "[yellow]No recover action specified[/yellow]": "[yellow]No recover action specified[/yellow]", "[yellow]No resume data found in checkpoint[/yellow]": "[yellow]No resume data found in checkpoint[/yellow]", "[yellow]No security action specified[/yellow]": "[yellow]No security action specified[/yellow]", "[yellow]No security configuration loaded[/yellow]": "[yellow]No security configuration loaded[/yellow]", "[yellow]No valid indices, keeping default selection.[/yellow]": "[yellow]No valid indices, keeping default selection.[/yellow]", "[yellow]Non-interactive mode, starting fresh download[/yellow]": "[yellow]Non-interactive mode, starting fresh download[/yellow]", "[yellow]Note: This change is temporary and will be lost on restart. Use config file for persistent changes.[/yellow]": "[yellow]Note: This change is temporary and will be lost on restart. Use config file for persistent changes.[/yellow]", "[yellow]Note: Update config file to persist locale setting[/yellow]": "[yellow]Note: Update config file to persist locale setting[/yellow]", "[yellow]Note:[/yellow] Configuration change is runtime-only": "[yellow]Note:[/yellow] Configuration change is runtime-only", "[yellow]Optimization cancelled[/yellow]": "[yellow]Optimization cancelled[/yellow]", "[yellow]Peer {peer_id} not found in allowlist[/yellow]": "[yellow]Peer {peer_id} not found in allowlist[/yellow]", "[yellow]Please provide the original torrent file or magnet link[/yellow]": "[yellow]Please provide the original torrent file or magnet link[/yellow]", "[yellow]Please use --v2 or --hybrid flags for now.[/yellow]": "[yellow]Please use --v2 or --hybrid flags for now.[/yellow]", "[yellow]Proxy configuration not found[/yellow]": "[yellow]Proxy configuration not found[/yellow]", "[yellow]Proxy configuration updated (skipped write in test mode)[/yellow]": "[yellow]Proxy configuration updated (skipped write in test mode)[/yellow]", "[yellow]Proxy has been disabled (skipped write in test mode)[/yellow]": "[yellow]Proxy has been disabled (skipped write in test mode)[/yellow]", "[yellow]Proxy is not enabled[/yellow]": "[yellow]Proxy is not enabled[/yellow]", "[yellow]Real-time monitoring not yet implemented[/yellow]": "[yellow]Real-time monitoring not yet implemented[/yellow]", "[yellow]Refresh completed with warnings[/yellow]": "[yellow]Refresh completed with warnings[/yellow]", "[yellow]Resume data validation found issues:[/yellow]": "[yellow]Resume data validation found issues:[/yellow]", "[yellow]Rich not available, starting fresh download[/yellow]": "[yellow]Rich not available, starting fresh download[/yellow]", "[yellow]Rule not found: {ip_range}[/yellow]": "[yellow]Rule not found: {ip_range}[/yellow]", "[yellow]SSL certificate verification disabled (not recommended). Configuration saved to {config_file}[/yellow]": "[yellow]SSL certificate verification disabled (not recommended). Configuration saved to {config_file}[/yellow]", "[yellow]SSL certificate verification disabled (not recommended, configuration not persisted - no config file)[/yellow]": "[yellow]SSL certificate verification disabled (not recommended, configuration not persisted - no config file)[/yellow]", "[yellow]SSL certificate verification disabled (not recommended, skipped write in test mode)[/yellow]": "[yellow]SSL certificate verification disabled (not recommended, skipped write in test mode)[/yellow]", "[yellow]SSL certificate verification enabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL certificate verification enabled (configuration not persisted - no config file)[/yellow]", "[yellow]SSL certificate verification enabled (skipped write in test mode)[/yellow]": "[yellow]SSL certificate verification enabled (skipped write in test mode)[/yellow]", "[yellow]SSL for peers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL for peers disabled (configuration not persisted - no config file)[/yellow]", "[yellow]SSL for peers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL for peers disabled (skipped write in test mode)[/yellow]", "[yellow]SSL for peers enabled (experimental, configuration not persisted - no config file)[/yellow]": "[yellow]SSL for peers enabled (experimental, configuration not persisted - no config file)[/yellow]", "[yellow]SSL for peers enabled (experimental, skipped write in test mode)[/yellow]": "[yellow]SSL for peers enabled (experimental, skipped write in test mode)[/yellow]", "[yellow]SSL for trackers disabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL for trackers disabled (configuration not persisted - no config file)[/yellow]", "[yellow]SSL for trackers disabled (skipped write in test mode)[/yellow]": "[yellow]SSL for trackers disabled (skipped write in test mode)[/yellow]", "[yellow]SSL for trackers enabled (configuration not persisted - no config file)[/yellow]": "[yellow]SSL for trackers enabled (configuration not persisted - no config file)[/yellow]", "[yellow]SSL for trackers enabled (skipped write in test mode)[/yellow]": "[yellow]SSL for trackers enabled (skipped write in test mode)[/yellow]", "[yellow]Select failed: {error}[/yellow]": "[yellow]Select failed: {error}[/yellow]", "[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]": "[yellow]Set --download-limit/--upload-limit for global limits; per-peer via config[/yellow]", "[yellow]Starting fresh download[/yellow]": "[yellow]Starting fresh download[/yellow]", "[yellow]TLS protocol version set to {version} (configuration not persisted - no config file)[/yellow]": "[yellow]TLS protocol version set to {version} (configuration not persisted - no config file)[/yellow]", "[yellow]TLS protocol version set to {version} (skipped write in test mode)[/yellow]": "[yellow]TLS protocol version set to {version} (skipped write in test mode)[/yellow]", "[yellow]The daemon process crashed during initialization.[/yellow]": "[yellow]The daemon process crashed during initialization.[/yellow]", "[yellow]The daemon process exited unexpectedly. Check daemon logs for error details.[/yellow]": "[yellow]The daemon process exited unexpectedly. Check daemon logs for error details.[/yellow]", "[yellow]This usually indicates a configuration error, missing dependency, or initialization failure.[/yellow]": "[yellow]This usually indicates a configuration error, missing dependency, or initialization failure.[/yellow]", "[yellow]Timeout waiting for daemon (last status: {last_status})[/yellow]": "[yellow]Timeout waiting for daemon (last status: {last_status})[/yellow]", "[yellow]To see errors in the terminal, run:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]": "[yellow]To see errors in the terminal, run:[/yellow] [dim]uv run btbt daemon start --foreground[/dim]", "[yellow]Toggle encryption via --enable-encryption/--disable-encryption on download/magnet[/yellow]": "[yellow]Toggle encryption via --enable-encryption/--disable-encryption on download/magnet[/yellow]", "[yellow]Torrent not found in queue[/yellow]": "[yellow]Torrent not found in queue[/yellow]", "[yellow]Torrent not found or not active. Resume data will be automatically saved when torrent completes.[/yellow]": "[yellow]Torrent not found or not active. Resume data will be automatically saved when torrent completes.[/yellow]", "[yellow]Torrent not found[/yellow]": "[yellow]Torrent not found[/yellow]", "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load or --save[/yellow]": "[yellow]Use --list/--list-active, --add, --remove, --clear-active, --test, --load or --save[/yellow]", "[yellow]Use -v flag for more details or try --foreground to see error output[/yellow]": "[yellow]Use -v flag for more details or try --foreground to see error output[/yellow]", "[yellow]Warning: Checkpoint save failed[/yellow]": "[yellow]Warning: Checkpoint save failed[/yellow]", "[yellow]Warning: Configuration changes require daemon restart, but restart was skipped.[/yellow]": "[yellow]Warning: Configuration changes require daemon restart, but restart was skipped.[/yellow]", "[yellow]Warning: Daemon is running. Diagnostics will test local session which may cause port conflicts.[/yellow]\n[dim]Consider stopping the daemon first: 'btbt daemon exit'[/dim]\n": "[yellow]Warning: Daemon is running. Diagnostics will test local session which may cause port conflicts.[/yellow]\n[dim]Consider stopping the daemon first: 'btbt daemon exit'[/dim]\n", "[yellow]Warning: Error saving checkpoint: {error}[/yellow]": "[yellow]Warning: Error saving checkpoint: {error}[/yellow]", "[yellow]Warning: Error stopping session: {e}[/yellow]": "[yellow]Warning: Error stopping session: {e}[/yellow]", "[yellow]Warning: Failed to save checkpoint: {error}[/yellow]": "[yellow]Warning: Failed to save checkpoint: {error}[/yellow]", "[yellow]Warning: Failed to select files: {error}[/yellow]": "[yellow]Warning: Failed to select files: {error}[/yellow]", "[yellow]Warning: Failed to set queue priority: {error}[/yellow]": "[yellow]Warning: Failed to set queue priority: {error}[/yellow]", "[yellow]Warning: IPC client not available[/yellow]": "[yellow]Warning: IPC client not available[/yellow]", "[yellow]Warning: SSL certificate verification is disabled while SSL is used in strict mode[/yellow]": "[yellow]Warning: SSL certificate verification is disabled while SSL is used in strict mode[/yellow]", "[yellow]Warning: V1 torrent generation is not yet implemented.[/yellow]": "[yellow]Warning: V1 torrent generation is not yet implemented.[/yellow]", "[yellow]Warning: certificate verification is disabled while SSL is in strict posture[/yellow]": "[yellow]Warning: certificate verification is disabled while SSL is in strict posture[/yellow]", "[yellow]Would delete {count} checkpoints older than {days} days:[/yellow]": "[yellow]Would delete {count} checkpoints older than {days} days:[/yellow]", "[yellow]{key} is not set[/yellow]": "[yellow]{key} is not set[/yellow]", "[yellow]⚠[/yellow] Could not save daemon config to config file: {e}": "[yellow]⚠[/yellow] Could not save daemon config to config file: {e}", "[yellow]⚠[/yellow] Daemon process started (PID {pid}) but may not be fully ready yet": "[yellow]⚠[/yellow] Daemon process started (PID {pid}) but may not be fully ready yet", "[yellow]⚠[/yellow] Daemon startup timeout after {timeout:.1f}s (last status: {last_status})": "[yellow]⚠[/yellow] Daemon startup timeout after {timeout:.1f}s (last status: {last_status})", "[yellow]⚠[/yellow] {errors} errors encountered": "[yellow]⚠[/yellow] {errors} errors encountered", "[yellow]✓[/yellow] Xet protocol disabled": "[yellow]✓[/yellow] Xet protocol disabled", "[yellow]✓[/yellow] uTP transport disabled": "[yellow]✓[/yellow] uTP transport disabled", "_get_executor() returned: executor=%s, is_daemon=%s": "_get_executor() returned: executor=%s, is_daemon=%s", "aiortc not installed": "aiortc ez dago instalatuta", "disabled": "ezindua", "enable_dht={value}": "enable_dht={value}", "enable_pex={value}": "enable_pex={value}", "enabled": "gaituta", "failed": "huts egin zuen", "fell": "erori zen", "http://tracker.example.com:8080/announce": "http://tracker.example.com:8080/announce", "no": "ez", "none": "bat ere ez", "not ready yet": "oraindik ez dago prest", "peers": "parekideak", "pieces": "piezak", "replace: file must be a full valid document; merge: deep-merge into existing target TOML then validate": "replace: file must be a full valid document; merge: deep-merge into existing target TOML then validate", "rose": "arrosa", "succeeded": "arrakasta izan zuen", "tonic share requires the daemon. Start it with: btbt daemon start": "tonic share requires the daemon. Start it with: btbt daemon start", "uTP": "uTP", "uTP (uTorrent Transport Protocol) Options:\n\nuTP provides reliable, ordered delivery over UDP with delay-based congestion control (BEP 29).\nUseful for better performance on networks with high latency or packet loss.": "uTP (uTorrent Transport Protocol) Options:\n\nuTP provides reliable, ordered delivery over UDP with delay-based congestion control (BEP 29).\nUseful for better performance on networks with high latency or packet loss.", "uTP Configuration": "uTP konfigurazioa", "uTP config": "uTP konfigurazioa", "uTP configuration reset to defaults via CLI": "uTP configuration reset to defaults via CLI", "uTP configuration updated: %s = %s": "uTP configuration updated: %s = %s", "uTP transport disabled via CLI": "uTP transport disabled via CLI", "uTP transport enabled": "uTP garraioa gaituta", "uTP transport enabled via CLI": "uTP transport enabled via CLI", "unknown": "ezezaguna", "unlimited": "mugagabea", "yes": "bai", "{connection} Torrents: {torrents} Active: {active} Paused: {paused} Seeding: {seeding} D: {download}B/s U: {upload}B/s": "{connection} Torrents: {torrents} Active: {active} Paused: {paused} Seeding: {seeding} D: {download}B/s U: {upload}B/s", "{graph_tab_id} - Data provider configuration error": "{graph_tab_id} - Data provider configuration error", "{graph_tab_id} - Data provider not available": "{graph_tab_id} - Data provider not available", "{hours:.1f}h ago": "{hours:.1f} duela ordu", "{key} = {value}": "{key} = {value}", "{key}: {value}": "{key}: {value}", "{minutes:.0f}m ago": "Duela {minutes:.0f}m", "{msg}\n\nPID file path: {path}": "{msg}\n\nPID file path: {path}", "{seconds:.0f}s ago": "Duela {seconds:.0f}", "{sub_tab} configuration - Coming soon": "{sub_tab} configuration - Coming soon", "{sub_tab} content for torrent {hash}... - Coming soon": "{sub_tab} content for torrent {hash}... - Coming soon", "{type} Configuration": "{type} Konfigurazioa", "↑ Rate": "↑ Tasa", "↑ Speed": "↑ Abiadura", "↓ Rate": "↓ Tarifa", "↓ Speed": "↓ Abiadura", "≥ 80% available": "≥% 80 eskuragarri", "⏸ Pause": "⏸ Pausa", "▶ Resume": "▶ Curriculuma", "⚠️ Daemon restart required to apply changes.\n": "⚠️ Daemon restart required to apply changes.\n", "✓ Configuration is valid": "✓ Configuration is valid", "✓ No system compatibility warnings": "✓ No system compatibility warnings", "✓ Verify": "✓ Egiaztatu", "✗ Configuration validation failed: {e}": "✗ Configuration validation failed: {e}", "📊 Refresh PEX": "📊 Freskatu PEX", "📥 Export State": "📥 Esportazio Estatua", "🔄 Reannounce": "🔄 Berriro iragarri", "🔍 Rehash": "🔍 Rehash", "🗑 Remove": "🗑 Kendu"}
diff --git a/ccbt/i18n/locale_data/fr_supplement.json b/ccbt/i18n/locale_data/fr_supplement.json
new file mode 100644
index 00000000..d465db08
--- /dev/null
+++ b/ccbt/i18n/locale_data/fr_supplement.json
@@ -0,0 +1 @@
+{"\nAvailable Commands:\n help - Show this help message\n status - Show current status\n peers - Show connected peers\n files - Show file information\n pause - Pause download\n resume - Resume download\n stop - Stop download\n quit - Quit application\n clear - Clear screen\n ": "\nAvailable Commands:\n help - Show this help message\n status - Show current status\n peers - Show connected peers\n files - Show file information\n pause - Pause download\n resume - Resume download\n stop - Stop download\n quit - Quit application\n clear - Clear screen\n ", "\n[bold]IP Filter Statistics[/bold]\n": "\n[bold]IP Filter Statistics[/bold]\n", "\n[bold]IP Filter Test[/bold]\n": "\n[bold]IP Filter Test[/bold]\n", "\n[cyan]Connection Diagnostics[/cyan]\n": "\n[cyan]Connection Diagnostics[/cyan]\n", "\n[cyan]Proxy Statistics:[/cyan]": "\n[cyan]Proxy Statistics:[/cyan]", "\n[cyan]Status:[/cyan] {status}": "\n[cyan]Status:[/cyan] {status}", "\n[dim]Press Ctrl+I in main dashboard to manage IPFS content and peers[/dim]": "\n[dim]Press Ctrl+I in main dashboard to manage IPFS content and peers[/dim]", "\n[dim]Press Ctrl+N in main dashboard to manage NAT settings globally[/dim]": "\n[dim]Press Ctrl+N in main dashboard to manage NAT settings globally[/dim]", "\n[dim]Press Ctrl+R in main dashboard to view scrape results[/dim]": "\n[dim]Press Ctrl+R in main dashboard to view scrape results[/dim]", "\n[dim]Press Ctrl+U in main dashboard to configure uTP settings globally[/dim]": "\n[dim]Press Ctrl+U in main dashboard to configure uTP settings globally[/dim]", "\n[dim]Press Ctrl+X in main dashboard to manage Xet settings globally[/dim]": "\n[dim]Press Ctrl+X in main dashboard to manage Xet settings globally[/dim]", "\n[green]Diagnostic complete![/green]": "\n[green]Diagnostic complete![/green]", "\n[green]✓ Discovery successful![/green]": "\n[green]✓ Discovery successful![/green]", "\n[green]✓[/green] No connection issues detected": "\n[green]✓[/green] No connection issues detected", "\n[yellow]2. DHT Status[/yellow]": "\n[yellow]2. DHT Status[/yellow]", "\n[yellow]3. Tracker Configuration[/yellow]": "\n[yellow]3. Tracker Configuration[/yellow]", "\n[yellow]4. NAT Configuration[/yellow]": "\n[yellow]4. NAT Configuration[/yellow]", "\n[yellow]5. Listen Port[/yellow]": "\n[yellow]5. Listen Port[/yellow]", "\n[yellow]6. Session Initialization Test[/yellow]": "\n[yellow]6. Session Initialization Test[/yellow]", "\n[yellow]Commands:[/yellow]": "\n[yellow]Commands:[/yellow]", "\n[yellow]Connection Issues[/yellow]": "\n[yellow]Connection Issues[/yellow]", "\n[yellow]Download interrupted by user[/yellow]": "\n[yellow]Download interrupted by user[/yellow]", "\n[yellow]File selection cancelled, using defaults[/yellow]": "\n[yellow]File selection cancelled, using defaults[/yellow]", "\n[yellow]Session Summary[/yellow]": "\n[yellow]Session Summary[/yellow]", "\n[yellow]Shutting down daemon...[/yellow]": "\n[yellow]Shutting down daemon...[/yellow]", "\n[yellow]TCP Server Status[/yellow]": "\n[yellow]TCP Server Status[/yellow]", "\n[yellow]Tracker Scrape Statistics:[/yellow]": "\n[yellow]Tracker Scrape Statistics:[/yellow]", "\n[yellow]Use: files select , files deselect , files priority [/yellow]": "\n[yellow]Use: files select , files deselect , files priority [/yellow]", "\n[yellow]Warning: No peers connected after 30 seconds[/yellow]": "\n[yellow]Warning: No peers connected after 30 seconds[/yellow]", "\n[yellow]✗ No NAT devices discovered[/yellow]": "\n[yellow]✗ No NAT devices discovered[/yellow]", " - {network} ({mode}, priority: {priority})": " - {network} ({mode}, priority: {priority})", " - {hash}... ({format})": "- {hash}... ({format})", " .tonic file: {path}": "Fichier .tonic : {path}", " Active Downloading: {count}": " Active Downloading: {count}", " Active Mappings: {mappings}": " Active Mappings: {mappings}", " Active Seeding: {count}": " Active Seeding: {count}", " Add the peer first using 'tonic allowlist add'": " Add the peer first using 'tonic allowlist add'", " Auth failures: {count}": " Auth failures: {count}", " Auto Map Ports: {status}": " Auto Map Ports: {status}", " Bypass list: {value}": "Liste de contournement : {value}", " Certificate: {path}": "Certificat : {path}", " Check interval: {seconds}": " Check interval: {seconds}", " Current mode: {mode}": "Mode actuel : {mode}", " DHT Enabled: {status}": " DHT Enabled: {status}", " DHT Port: {port}": "Port DHT : {port}", " DHT Routing Table: {size} nodes": " DHT Routing Table: {size} nodes", " Default sync mode: {mode}": " Default sync mode: {mode}", " Enabled: {enabled}": "Activé : {enabled}", " External IP: {ip}": "IP externe : {ip}", " External: {port}": "Externe : {port}", " Failed: {count}": "Échec : {count}", " Folder key: {folder_key}": " Folder key: {folder_key}", " Folder key: {key}": "Clé du dossier : {key}", " For peers: {value}": "Pour les pairs : {value}", " For trackers: {value}": " For trackers: {value}", " For webseeds: {value}": " For webseeds: {value}", " HTTP Trackers: {status}": " HTTP Trackers: {status}", " Host: {host}:{port}": "Hôte : {host} :{port}", " Internal: {port}": "Interne : {port}", " Key: {path}": "Clé : {path}", " Make sure NAT traversal is enabled and a device is discovered": " Make sure NAT traversal is enabled and a device is discovered", " Make sure NAT-PMP or UPnP is enabled on your router": " Make sure NAT-PMP or UPnP is enabled on your router", " Mode: {mode}": "Mode : {mode}", " NAT-PMP: {status}": "NAT-PMP : {status}", " Output directory: {dir}": " Output directory: {dir}", " Paused: {count}": "En pause : {count}", " Protocol enabled: {enabled}": " Protocol enabled: {enabled}", " Protocol not active (session may not be running)": " Protocol not active (session may not be running)", " Protocol: {method}": "Protocole : {method}", " Protocol: {protocol}": "Protocole : {protocol}", " Queued: {count}": "En file d'attente : {count}", " Running: {status}": "En cours d'exécution : {status}", " Serving: {status}": "Portion : {status}", " Sessions with Peers: {count}": " Sessions with Peers: {count}", " Source peers: {peers}": " Source peers: {peers}", " Successful: {count}": "Réussite : {count}", " Supports DHT: {enabled}": " Supports DHT: {enabled}", " Supports PEX: {enabled}": " Supports PEX: {enabled}", " Supports XET: {enabled}": " Supports XET: {enabled}", " TCP Enabled: {status}": " TCP Enabled: {status}", " TCP Port: {port}": "Port TCP : {port}", " Total Connections: {count}": " Total Connections: {count}", " Total Sessions: {count}": " Total Sessions: {count}", " Total connections: {count}": " Total connections: {count}", " Total: {count}": "Total : {count}", " Type: {type}": "Tapez : {type}", " UDP Trackers: {status}": " UDP Trackers: {status}", " UPnP: {status}": "UPnP : {status}", " Use 'ccbt tonic status' to check sync status": " Use 'ccbt tonic status' to check sync status", " Username: {username}": "Nom d'utilisateur : {username}", " Workspace ID: {id}": "ID de l'espace de travail : {id}", " Workspace sync enabled: {enabled}": " Workspace sync enabled: {enabled}", " XET port: {port}": "Port XET : {port}", " [cyan]Allowed:[/cyan] {allows}": " [cyan]Allowed:[/cyan] {allows}", " [cyan]Blocked:[/cyan] {blocks}": " [cyan]Blocked:[/cyan] {blocks}", " [cyan]Enabled:[/cyan] {enabled}": " [cyan]Enabled:[/cyan] {enabled}", " [cyan]IP Address:[/cyan] {ip}": " [cyan]IP Address:[/cyan] {ip}", " [cyan]IPv4 Ranges:[/cyan] {ipv4_ranges}": " [cyan]IPv4 Ranges:[/cyan] {ipv4_ranges}", " [cyan]IPv6 Ranges:[/cyan] {ipv6_ranges}": " [cyan]IPv6 Ranges:[/cyan] {ipv6_ranges}", " [cyan]Last Update:[/cyan] Never": " [cyan]Last Update:[/cyan] Never", " [cyan]Last Update:[/cyan] {timestamp}": " [cyan]Last Update:[/cyan] {timestamp}", " [cyan]Mode:[/cyan] {mode}": " [cyan]Mode:[/cyan] {mode}", " [cyan]Status:[/cyan] {status}": " [cyan]Status:[/cyan] {status}", " [cyan]Total Checks:[/cyan] {matches}": " [cyan]Total Checks:[/cyan] {matches}", " [cyan]Total Rules:[/cyan] {total_rules}": " [cyan]Total Rules:[/cyan] {total_rules}", " [cyan]deselect [/cyan] - Deselect a file": " [cyan]deselect [/cyan] - Deselect a file", " [cyan]deselect-all[/cyan] - Deselect all files": " [cyan]deselect-all[/cyan] - Deselect all files", " [cyan]done[/cyan] - Finish selection and start download": " [cyan]done[/cyan] - Finish selection and start download", " [cyan]priority [/cyan] - Set priority (do_not_download/low/normal/high/maximum)": " [cyan]priority [/cyan] - Set priority (do_not_download/low/normal/high/maximum)", " [cyan]select [/cyan] - Select a file": " [cyan]select [/cyan] - Select a file", " [cyan]select-all[/cyan] - Select all files": " [cyan]select-all[/cyan] - Select all files", " [green]✓[/green] Can bind to port {port}": " [green]✓[/green] Can bind to port {port}", " [green]✓[/green] Session initialized successfully": " [green]✓[/green] Session initialized successfully", " [green]✓[/green] TCP server initialized": " [green]✓[/green] TCP server initialized", " [green]✓[/green] {url}: {loaded} rules": " [green]✓[/green] {url}: {loaded} rules", " [red]✗[/red] Cannot bind to port: {e}": " [red]✗[/red] Cannot bind to port: {e}", " [red]✗[/red] NAT manager not initialized": " [red]✗[/red] NAT manager not initialized", " [red]✗[/red] Session initialization failed: {e}": " [red]✗[/red] Session initialization failed: {e}", " [red]✗[/red] TCP server not initialized": " [red]✗[/red] TCP server not initialized", " [red]✗[/red] {url}: failed": " [red]✗[/red] {url}: failed", " [yellow]⚠[/yellow] DHT client not initialized": " [yellow]⚠[/yellow] DHT client not initialized", " [yellow]⚠[/yellow] TCP server not initialized": " [yellow]⚠[/yellow] TCP server not initialized", " uTP Enabled: {status}": " uTP Enabled: {status}", " {msg}": "{msg}", " {warning}": "{warning}", " • Check if torrent has active seeders": " • Check if torrent has active seeders", " • Ensure DHT is enabled: --enable-dht": " • Ensure DHT is enabled: --enable-dht", " • Run 'btbt diagnose-connections' to check connection status": " • Run 'btbt diagnose-connections' to check connection status", " • Verify NAT/firewall settings": " • Verify NAT/firewall settings", " ⚠ {warning}": "⚠ {warning}", " (checkpoint restored)": "(point de contrôle restauré)", " (checkpoint saved)": "(point de contrôle enregistré)", " (no checkpoint found)": "(aucun point de contrôle trouvé)", " +{count} more": "+{count} plus", " | Files: {selected}/{total} selected": " | Files: {selected}/{total} selected", " | Private: {count}": " | Private: {count}", "(no options set)": "(aucune option définie)", "- [yellow]{issue}[/yellow]": "- [yellow]{issue}[/yellow]", "- {id}: {severity} rule={rule} value={value}": "- {id}: {severity} rule={rule} value={value}", "- {name}: metric={metric}, cond={condition}, severity={severity}": "- {name}: metric={metric}, cond={condition}, severity={severity}", "... and {count} more": "... et {count} plus", "0.1 ms (adaptive)": "0,1 ms (adaptatif)", "1 MB (adaptive)": "1 Mo (adaptatif)", "1-2": "1-2", "2-4": "2-4", "25–49% available": "25 à 49 % disponibles", "4-8": "4-8", "5 ms (adaptive)": "5 ms (adaptatif)", "50 ms (adaptive)": "50 ms (adaptatif)", "50–79% available": "50 à 79 % disponible", "512 KB (adaptive)": "512 Ko (adaptatif)", "64 KB (adaptive)": "64 Ko (adaptatif)", "ACK Interval": "Intervalle ACK", "ACK packet send interval": "ACK packet send interval", "API key or Ed25519 key manager required for WebSocket connection": "API key or Ed25519 key manager required for WebSocket connection", "Action": "Action", "Actions": "Actes", "Active": "Active", "Active Alerts": "Active Alerts", "Active Block Requests": "Demandes de blocage actif", "Active Nodes": "Nœuds actifs", "Active Torrents": "Torrents actifs", "Active: {count}": "Active: {count}", "Adaptive": "Adaptatif", "Add": "Ajouter", "Add Torrents": "Ajouter des torrents", "Add Tracker": "Ajouter un suivi", "Add magnet succeeded but no info_hash returned": "Add magnet succeeded but no info_hash returned", "Add to Session": "Ajouter à la séance", "Advanced": "Avancé", "Advanced Add": "Advanced Add", "Advanced add torrent": "Ajout avancé d'un torrent", "Advanced configuration (experimental features)": "Advanced configuration (experimental features)", "Advanced configuration - Data provider/Executor not available": "Advanced configuration - Data provider/Executor not available", "Aggressive": "Agressif", "Aggressive Mode": "Mode agressif", "Alert Rules": "Alert Rules", "Alerts": "Alerts", "Alerts dashboard": "Tableau de bord des alertes", "All {total} file(s) verified successfully": "All {total} file(s) verified successfully", "Announce sent": "Annonce envoyée", "Announce: Failed": "Announce: Failed", "Announce: {status}": "Announce: {status}", "Apply": "Appliquer", "Are you sure you want to quit?": "Are you sure you want to quit?", "Authentication failed when checking daemon status at %s (status %d). This usually indicates an API key mismatch. Check that the API key in config matches the daemon's API key.": "Authentication failed when checking daemon status at %s (status %d). This usually indicates an API key mismatch. Check that the API key in config matches the daemon's API key.", "Auto-scrape on Add:": "Scrape automatique lors de l'ajout :", "Auto-tuned configuration saved to {path}": "Auto-tuned configuration saved to {path}", "Auto-tuning warnings:": "Avertissements de réglage automatique :", "Automatically restart daemon if needed (without prompt)": "Automatically restart daemon if needed (without prompt)", "Availability": "Disponibilité", "Availability Trend": "Tendance de disponibilité", "Availability {direction} {delta:+.1f}pp": "Availability {direction} {delta:+.1f}pp", "Available keys: {keys}": "Clés disponibles : {keys}", "Available locales: {locales}": "Available locales: {locales}", "Average Quality": "Qualité moyenne", "Avg Download Rate": "Taux de téléchargement moyen", "Avg Quality": "Qualité moyenne", "Avg Upload Rate": "Taux de téléchargement moyen", "Backup complete": "Sauvegarde terminée", "Backup created: {path}": "Sauvegarde créée : {path}", "Backup destination path": "Backup destination path", "Backup failed": "La sauvegarde a échoué", "Ban Peer": "Interdire les pairs", "Bandwidth": "Bande passante", "Bandwidth Utilization": "Utilisation de la bande passante", "Bandwidth configuration - Data provider/Executor not available": "Bandwidth configuration - Data provider/Executor not available", "Blacklist Size": "Taille de la liste noire", "Blacklisted IPs ({count})": "Blacklisted IPs ({count})", "Blacklisted Peers": "Pairs sur liste noire", "Block size (KiB)": "Taille du bloc (Kio)", "Blocked Connections": "Connexions bloquées", "Bootstrap Nodes": "Nœuds d'amorçage", "Bootstrap health": "Santé du bootstrap", "Bootstrap recovery attempts": "Bootstrap recovery attempts", "Browse": "Browse", "Browse and add torrent": "Parcourir et ajouter un torrent", "Bytes Downloaded": "Octets téléchargés", "Bytes Uploaded": "Octets téléchargés", "CPU": "Processeur", "CRITICAL: PID file exists (initial=%s, current=%s, path=%s) but code reached local session creation! This will cause port conflicts. Aborting.": "CRITICAL: PID file exists (initial=%s, current=%s, path=%s) but code reached local session creation! This will cause port conflicts. Aborting.", "Cache Statistics": "Statistiques du cache", "Cache entries: {count}": "Entrées du cache : {count}", "Cache hit rate: {rate:.2f}%": "Cache hit rate: {rate:.2f}%", "Cache size: {size} bytes": "Cache size: {size} bytes", "Cached Scrape Results": "Résultats de scraping mis en cache", "Cached: {cache_size}, Total Seeders: {seeders}, Total Leechers: {leechers}": "Cached: {cache_size}, Total Seeders: {seeders}, Total Leechers: {leechers}", "Cancel": "Annuler", "Cancel Editing": "Annuler la modification", "Cannot auto-resume checkpoint": "Cannot auto-resume checkpoint", "Cannot connect to daemon at %s: %s (daemon may not be running or IPC server not started)": "Cannot connect to daemon at %s: %s (daemon may not be running or IPC server not started)", "Cannot connect to daemon. Start daemon with: 'btbt daemon start'": "Cannot connect to daemon. Start daemon with: 'btbt daemon start'", "Cannot specify both --hybrid and --v1": "Cannot specify both --hybrid and --v1", "Cannot specify both --v2 and --hybrid": "Cannot specify both --v2 and --hybrid", "Cannot specify both --v2 and --v1": "Cannot specify both --v2 and --v1", "Capability": "Capability", "Catppuccin": "Catpuccine", "Checkpoint directory": "Répertoire des points de contrôle", "Choked": "Étouffé", "Choose a playable file first.": "Choose a playable file first.", "Choose a theme": "Choisissez un thème", "Cleaning up old checkpoints...": "Cleaning up old checkpoints...", "Cleanup complete": "Nettoyage terminé", "Click on 'Global' tab to configure this section": "Click on 'Global' tab to configure this section", "Client": "Client", "Client error checking daemon status at %s: %s (daemon may be starting up)": "Client error checking daemon status at %s: %s (daemon may be starting up)", "Close": "Fermer", "Closest Nodes": "Nœuds les plus proches", "Command '{cmd}' executed successfully": "Command '{cmd}' executed successfully", "Command '{cmd}' failed": "La commande '{cmd}' a échoué", "Command executor not available": "Command executor not available", "Command executor or data provider not available": "Command executor or data provider not available", "Commands: ": "Commands: ", "Completed": "Completed", "Completed (Scrape)": "Completed (Scrape)", "Component": "Component", "Compress backup (default: yes)": "Compress backup (default: yes)", "Compressing backup...": "Compression de la sauvegarde...", "Condition": "Condition", "Config": "Configuration", "Config Backups": "Config Backups", "Configuration": "Configuration", "Configuration differences:": "Configuration differences:", "Configuration exported to {path}": "Configuration exported to {path}", "Configuration file path": "Configuration file path", "Configuration imported to {path}": "Configuration imported to {path}", "Configuration options": "Options de configuration", "Configuration restored from {path}": "Configuration restored from {path}", "Configuration saved successfully": "Configuration saved successfully", "Configuration saved successfully!": "Configuration saved successfully!", "Configuration saved successfully.\n": "Configuration saved successfully.\n", "Configuration section": "Section Configuration", "Configuration: {type}\n\nThis configuration section is not yet fully implemented.": "Configuration: {type}\n\nThis configuration section is not yet fully implemented.", "Confirm": "Confirm", "Connected": "Connected", "Connected Peers": "Connected Peers", "Connected Torrents": "Torrents connectés", "Connected to {peers} peer(s), fetching metadata...": "Connected to {peers} peer(s), fetching metadata...", "Connecting to daemon at %s (PID file exists, config_path=%s)": "Connecting to daemon at %s (PID file exists, config_path=%s)", "Connecting to daemon at %s (config_path=%s)": "Connecting to daemon at %s (config_path=%s)", "Connecting to peers...": "Se connecter à ses pairs...", "Connection Duration": "Durée de connexion", "Connection Efficiency": "Efficacité de la connexion", "Connection Pool Statistics": "Connection Pool Statistics", "Connection Timeout": "Délai de connexion", "Connection timeout (s)": "Délai(s) d'expiration de connexion", "Connection timeout in seconds": "Connection timeout in seconds", "Connections: {connections} | Packets: {sent}/{received} | Bytes: {bytes_sent}/{bytes_received}": "Connections: {connections} | Packets: {sent}/{received} | Bytes: {bytes_sent}/{bytes_received}", "Connections: {connections}, Signaling: {signaling} ({host}:{port})": "Connections: {connections}, Signaling: {signaling} ({host}:{port})", "Controls": "Contrôles", "Copy Info Hash": "Copier le hachage des informations", "Could not connect to daemon (no PID file): %s - will create local session": "Could not connect to daemon (no PID file): %s - will create local session", "Could not find file index": "Could not find file index", "Could not get torrent output directory": "Could not get torrent output directory", "Could not load torrent: {path}": "Could not load torrent: {path}", "Could not read daemon config from ConfigManager: %s": "Could not read daemon config from ConfigManager: %s", "Could not save daemon config to config file: %s": "Could not save daemon config to config file: %s", "Could not send shutdown request, using signal...": "Could not send shutdown request, using signal...", "Count": "Compter", "Count: {count}{file_info}{private_info}": "Count: {count}{file_info}{private_info}", "Create Torrent": "Créer un torrent", "Create backup before migration": "Create backup before migration", "Creating backup...": "Création d'une sauvegarde...", "Cross-Torrent Sharing": "Partage cross-torrent", "Current": "Actuel", "Current Value": "Valeur actuelle", "Current chunks: {count}": "Current chunks: {count}", "Current locale: {locale}": "Current locale: {locale}", "DHT": "DHT", "DHT Aggressive Mode:": "Mode agressif DHT :", "DHT Health": "Santé DHT", "DHT Health (daemon)": "Santé DHT (démon)", "DHT Health Hotspots": "Points chauds de santé DHT", "DHT Metrics": "Métriques DHT", "DHT Statistics": "Statistiques DHT", "DHT Status": "Statut DHT", "DHT aggressive mode {status}": "DHT aggressive mode {status}", "DHT client not available. DHT metrics require DHT to be enabled and running.": "DHT client not available. DHT metrics require DHT to be enabled and running.", "DHT data is unavailable in the current mode.": "DHT data is unavailable in the current mode.", "DHT is not running.": "DHT ne fonctionne pas.", "DHT is running but no active nodes yet.": "DHT is running but no active nodes yet.", "DHT is running. {active} active nodes, {peers} peers found.": "DHT is running. {active} active nodes, {peers} peers found.", "DHT port": "Port DHT", "DHT timeout (s)": "Délai (s) d'expiration DHT", "Daemon PID file exists but API key not found (config or daemon config file). Cannot route to daemon. Please check daemon configuration.": "Daemon PID file exists but API key not found (config or daemon config file). Cannot route to daemon. Please check daemon configuration.", "Daemon PID file exists but cannot connect to daemon (error: {error}).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check if IPC server is running on the configured port\n 3. Verify API key in config matches daemon's API key\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but cannot connect to daemon (error: {error}).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check if IPC server is running on the configured port\n 3. Verify API key in config matches daemon's API key\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but cannot connect to daemon: {error}\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but cannot connect to daemon: {error}\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check IPC port configuration matches daemon port\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not accessible after {elapsed:.1f}s.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for startup errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not accessible after {elapsed:.1f}s.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for startup errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not responding (timeout after {elapsed:.1f}s).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not responding (timeout after {elapsed:.1f}s).\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for errors\n 3. If daemon crashed, restart it: 'btbt daemon start'\n 4. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but daemon is not responding after {max_total_wait:.1f}s.\nPossible causes:\n - Daemon is still starting up (wait a few seconds and try again)\n - Daemon crashed (check logs or run 'btbt daemon status')\n - IPC server is not accessible (check firewall/network settings)\n\nTo resolve:\n 1. Run 'btbt daemon status' to check if daemon is actually running\n 2. If daemon is not running, remove stale PID file: 'btbt daemon exit --force'\n 3. If you want to run locally instead, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but daemon is not responding after {max_total_wait:.1f}s.\nPossible causes:\n - Daemon is still starting up (wait a few seconds and try again)\n - Daemon crashed (check logs or run 'btbt daemon status')\n - IPC server is not accessible (check firewall/network settings)\n\nTo resolve:\n 1. Run 'btbt daemon status' to check if daemon is actually running\n 2. If daemon is not running, remove stale PID file: 'btbt daemon exit --force'\n 3. If you want to run locally instead, stop the daemon: 'btbt daemon exit'", "Daemon PID file exists but error occurred while connecting: {error}.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for connection errors\n 3. Verify IPC server is accessible on the configured port\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'": "Daemon PID file exists but error occurred while connecting: {error}.\nThe daemon may be starting up or may have crashed.\n\nTo resolve:\n 1. Run 'btbt daemon status' to check daemon state\n 2. Check daemon logs for connection errors\n 3. Verify IPC server is accessible on the configured port\n 4. If daemon crashed, restart it: 'btbt daemon start'\n 5. If you want to run locally, stop the daemon: 'btbt daemon exit'", "Daemon connection error (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Daemon connection error (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...", "Daemon connection timeout (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Daemon connection timeout (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Daemon connection: config_path=%s, file_exists=%s": "Daemon connection: config_path=%s, file_exists=%s", "Daemon is accessible and ready (attempt %d/%d, took %.1fs)": "Daemon is accessible and ready (attempt %d/%d, took %.1fs)", "Daemon is marked as running but not accessible (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Daemon is marked as running but not accessible (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Daemon is marked as running but not accessible after %d attempts (elapsed %.1fs)": "Daemon is marked as running but not accessible after %d attempts (elapsed %.1fs)", "Daemon is not running": "Le démon ne fonctionne pas", "Daemon is not running, nothing to restart": "Daemon is not running, nothing to restart", "Daemon is not running, restart not needed": "Daemon is not running, restart not needed", "Daemon is not running. File management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. File management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. NAT management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. NAT management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. Queue management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. Queue management commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon is not running. Scrape commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'": "Daemon is not running. Scrape commands require the daemon to be running.\nStart the daemon with: 'btbt daemon start'", "Daemon restarted successfully (PID: %d)": "Daemon restarted successfully (PID: %d)", "Daemon stopped": "Démon arrêté", "Daemon stopped gracefully": "Daemon stopped gracefully", "Dark": "Sombre", "Dark Mode": "Mode sombre", "Dashboard Error": "Erreur du tableau de bord", "Data": "Données", "Data provider or command executor not available": "Data provider or command executor not available", "Default": "Défaut", "Default (Light)": "Par défaut (clair)", "Delete torrent {info_hash}…? Press 'y' to confirm or 'n' to cancel": "Delete torrent {info_hash}…? Press 'y' to confirm or 'n' to cancel", "Depth": "Profondeur", "Description": "Description", "Description: {desc}": "Description : {desc}", "Deselect All": "Désélectionner tout", "Deselect folder": "Désélectionner le dossier", "Deselected {count} file(s)": "Deselected {count} file(s)", "Details": "Details", "Diff written to {path}": "Diff écrit dans {path}", "Direct session access not available in daemon mode": "Direct session access not available in daemon mode", "Disable DHT": "Désactiver DHT", "Disable HTTP trackers": "Désactiver les trackers HTTP", "Disable IPv6": "Désactiver IPv6", "Disable Protocol v2 (BEP 52)": "Disable Protocol v2 (BEP 52)", "Disable TCP transport": "Désactiver le transport TCP", "Disable TCP_NODELAY": "Désactiver TCP_NODELAY", "Disable UDP trackers": "Désactiver les trackers UDP", "Disable checkpointing": "Désactiver les points de contrôle", "Disable io_uring usage": "Désactiver l'utilisation de io_uring", "Disable memory mapping": "Désactiver le mappage de la mémoire", "Disable metrics": "Désactiver les métriques", "Disable protocol encryption": "Disable protocol encryption", "Disable sparse files": "Désactiver les fichiers fragmentés", "Disable splash screen (useful for debugging)": "Disable splash screen (useful for debugging)", "Disable uTP transport": "Désactiver le transport uTP", "Disabled": "Disabled", "Disk": "Disque", "Disk I/O Configuration": "Configuration des E/S disque", "Disk I/O Statistics": "Statistiques d'E/S disque", "Disk I/O configuration (preallocation, hashing, checkpoints)": "Disk I/O configuration (preallocation, hashing, checkpoints)", "Disk I/O metrics - Error: {error}": "Disk I/O metrics - Error: {error}", "Disk I/O workers": "Travailleurs d'E/S disque", "Disk IO": "E/S disque", "Disk Workers": "Travailleurs de disque", "Do Not Download": "Ne pas télécharger", "Down (B/s)": "Vers le bas (B/s)", "Down/Up (B/s)": "Bas/Haut (B/s)", "Download Limit": "Limite de téléchargement", "Download Limit (KiB/s):": "Download Limit (KiB/s):", "Download Rate": "Taux de téléchargement", "Download Rate Limit (bytes/sec, 0 = unlimited):": "Download Rate Limit (bytes/sec, 0 = unlimited):", "Download Speed": "Download Speed", "Download Trend": "Télécharger la tendance", "Download cancelled{checkpoint_info}": "Download cancelled{checkpoint_info}", "Download force started": "Le téléchargement forcé a commencé", "Download limit (KiB/s, 0 = unlimited)": "Download limit (KiB/s, 0 = unlimited)", "Download paused{checkpoint_info}": "Download paused{checkpoint_info}", "Download resumed{checkpoint_info}": "Download resumed{checkpoint_info}", "Download stopped": "Download stopped", "Download swing {delta:.1f} KiB/s (peak {peak:.1f} KiB/s)": "Download swing {delta:.1f} KiB/s (peak {peak:.1f} KiB/s)", "Download:": "Télécharger:", "Downloaded": "Downloaded", "Downloaders": "Téléchargeurs", "Downloading": "Téléchargement", "Downloading {name}": "Downloading {name}", "Dracula": "Dracula", "Duplicate Requests Prevented": "Duplicate Requests Prevented", "Duration": "Durée", "ETA": "ETA", "Editing: {section}": "Édition : {section}", "Enable Compression:": "Activer la compression :", "Enable DHT": "Activer DHT", "Enable Deduplication:": "Activer la déduplication :", "Enable HTTP trackers": "Activer les trackers HTTP", "Enable IPFS Protocol:": "Activer le protocole IPFS :", "Enable IPv6": "Activer IPv6", "Enable NAT Port Mapping:": "Enable NAT Port Mapping:", "Enable P2P Content-Addressed Storage:": "Enable P2P Content-Addressed Storage:", "Enable Protocol v2 (BEP 52)": "Enable Protocol v2 (BEP 52)", "Enable TCP transport": "Activer le transport TCP", "Enable TCP_NODELAY": "Activer TCP_NODELAY", "Enable UDP trackers": "Activer les trackers UDP", "Enable Xet Protocol:": "Activer le protocole Xet :", "Enable debug mode (deprecated, use -vv)": "Enable debug mode (deprecated, use -vv)", "Enable debug verbosity (equivalent to -vv)": "Enable debug verbosity (equivalent to -vv)", "Enable direct I/O for writes when supported": "Enable direct I/O for writes when supported", "Enable fsync after batched writes": "Enable fsync after batched writes", "Enable io_uring on Linux if available": "Enable io_uring on Linux if available", "Enable metrics": "Activer les métriques", "Enable monitoring": "Activer la surveillance", "Enable protocol encryption": "Enable protocol encryption", "Enable sparse files": "Activer les fichiers fragmentés", "Enable streaming mode": "Activer le mode diffusion", "Enable trace verbosity (equivalent to -vvv)": "Enable trace verbosity (equivalent to -vvv)", "Enable uTP Transport:": "Activer le transport uTP :", "Enable uTP transport": "Activer le transport uTP", "Enabled": "Enabled", "Enabled (Dependency Missing)": "Enabled (Dependency Missing)", "Enabled (Not Started)": "Activé (non démarré)", "Encrypt backup with generated key": "Encrypt backup with generated key", "Encrypting backup...": "Chiffrement de la sauvegarde...", "Endgame duplicate requests": "Endgame duplicate requests", "Endgame threshold (0..1)": "Endgame threshold (0..1)", "Enter Tracker URL": "Entrez l'URL du tracker", "Enter path...": "Entrez le chemin...", "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.": "Enter the directory where files should be downloaded:\n\nLeave empty to use current directory.", "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...": "Enter the path to a .torrent file or a magnet link:\n\nExamples:\n /path/to/file.torrent\n magnet:?xt=urn:btih:...", "Enter torrent file path or magnet link": "Enter torrent file path or magnet link", "Enter torrent file path or magnet link:": "Enter torrent file path or magnet link:", "Error": "Erreur", "Error adding tracker: {error}": "Error adding tracker: {error}", "Error banning peer: {error}": "Error banning peer: {error}", "Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...": "Error checking daemon accessibility (attempt %d/%d, elapsed %.1fs): %s, retrying in %.1fs...", "Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s": "Error checking daemon accessibility after %d attempts (elapsed %.1fs): %s", "Error checking daemon stage: %s": "Error checking daemon stage: %s", "Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection": "Error checking if daemon is running (Windows-specific issue?): %s - PID file exists, will attempt IPC connection", "Error checking if restart is needed: %s": "Error checking if restart is needed: %s", "Error closing HTTP session: %s": "Error closing HTTP session: %s", "Error closing IPC client: %s": "Error closing IPC client: %s", "Error closing WebSocket: %s": "Error closing WebSocket: %s", "Error comparing configs: {e}": "Error comparing configs: {e}", "Error creating backup: {e}": "Error creating backup: {e}", "Error creating torrent": "Erreur lors de la création du torrent", "Error deselecting files: {error}": "Error deselecting files: {error}", "Error executing config.get command: {error}": "Error executing config.get command: {error}", "Error executing {operation} on daemon: {error}": "Error executing {operation} on daemon: {error}", "Error exporting configuration: {e}": "Error exporting configuration: {e}", "Error forcing announce: {error}": "Error forcing announce: {error}", "Error generating schema: {e}": "Error generating schema: {e}", "Error getting DHT stats: {error}": "Error getting DHT stats: {error}", "Error getting daemon status": "Error getting daemon status", "Error getting daemon status: %s": "Error getting daemon status: %s", "Error importing configuration: {e}": "Error importing configuration: {e}", "Error in socket pre-check: %s": "Error in socket pre-check: %s", "Error listing backups: {e}": "Error listing backups: {e}", "Error listing profiles: {e}": "Error listing profiles: {e}", "Error listing templates: {e}": "Error listing templates: {e}", "Error loading DHT data: {error}": "Error loading DHT data: {error}", "Error loading DHT summary: {error}": "Error loading DHT summary: {error}", "Error loading configuration: {error}": "Error loading configuration: {error}", "Error loading info: {error}": "Error loading info: {error}", "Error loading peer data: {error}": "Error loading peer data: {error}", "Error loading section: {error}": "Error loading section: {error}", "Error loading security data: {error}": "Error loading security data: {error}", "Error loading torrent config: {error}": "Error loading torrent config: {error}", "Error loading torrent: {error}": "Error loading torrent: {error}", "Error opening folder: {error}": "Error opening folder: {error}", "Error processing file %s: %s": "Error processing file %s: %s", "Error reading PID file after retries: %s": "Error reading PID file after retries: %s", "Error reading PID file: %s": "Error reading PID file: %s", "Error reading scrape cache": "Error reading scrape cache", "Error receiving WebSocket event: %s": "Error receiving WebSocket event: %s", "Error receiving WebSocket events batch: %s": "Error receiving WebSocket events batch: %s", "Error removing tracker: {error}": "Error removing tracker: {error}", "Error restarting daemon": "Error restarting daemon", "Error restoring backup: {e}": "Error restoring backup: {e}", "Error routing to daemon (PID file exists): %s": "Error routing to daemon (PID file exists): %s", "Error routing to daemon (no PID file): %s - will create local session": "Error routing to daemon (no PID file): %s - will create local session", "Error saving configuration: {error}": "Error saving configuration: {error}", "Error selecting files: {error}": "Error selecting files: {error}", "Error sending shutdown request: %s": "Error sending shutdown request: %s", "Error setting DHT aggressive mode: {error}": "Error setting DHT aggressive mode: {error}", "Error setting file priority: {error}": "Error setting file priority: {error}", "Error starting daemon": "Erreur lors du démarrage du démon", "Error stopping daemon": "Erreur lors de l'arrêt du démon", "Error stopping session: %s": "Error stopping session: %s", "Error submitting form: {error}": "Error submitting form: {error}", "Error verifying files: {error}": "Error verifying files: {error}", "Error waiting for daemon with progress: %s": "Error waiting for daemon with progress: %s", "Error waiting for daemon: %s": "Error waiting for daemon: %s", "Error waiting for metadata: %s": "Error waiting for metadata: %s", "Error with auto-tuning: {e}": "Error with auto-tuning: {e}", "Error with profile: {e}": "Error with profile: {e}", "Error with template: {e}": "Error with template: {e}", "Error: {error}": "Erreur : {error}", "Errors": "Erreurs", "Estimated Read Speed": "Vitesse de lecture estimée", "Estimated Write Speed": "Vitesse d'écriture estimée", "Events": "Événements", "Eviction rate: {rate:.2f} /sec": "Eviction rate: {rate:.2f} /sec", "Exceeded maximum wait time (%.1fs) for daemon readiness": "Exceeded maximum wait time (%.1fs) for daemon readiness", "Excellent": "Excellent", "Exists": "Existe", "Expected info hash (hex)": "Expected info hash (hex)", "Expected type: {type_name}": "Expected type: {type_name}", "Explore": "Explore", "Export complete": "Exportation terminée", "Exporting checkpoint...": "Exporting checkpoint...", "Failed": "Failed", "Failed Requests": "Demandes ayant échoué", "Failed to add content": "Échec de l'ajout de contenu", "Failed to add magnet link": "Failed to add magnet link", "Failed to add peer to allowlist": "Failed to add peer to allowlist", "Failed to add to queue": "Échec de l'ajout à la file d'attente", "Failed to add torrent": "Échec de l'ajout du torrent", "Failed to add torrent to daemon": "Failed to add torrent to daemon", "Failed to add tracker": "Échec de l'ajout du tracker", "Failed to add tracker: {error}": "Failed to add tracker: {error}", "Failed to announce: {error}": "Failed to announce: {error}", "Failed to ban peer: {error}": "Failed to ban peer: {error}", "Failed to calculate progress: %s": "Failed to calculate progress: %s", "Failed to cancel torrent": "Failed to cancel torrent", "Failed to cleanup Xet cache": "Failed to cleanup Xet cache", "Failed to clear queue": "Échec de la suppression de la file d'attente", "Failed to collect custom metrics: %s": "Failed to collect custom metrics: %s", "Failed to collect performance metrics: %s": "Failed to collect performance metrics: %s", "Failed to collect system metrics: %s": "Failed to collect system metrics: %s", "Failed to copy info hash: {error}": "Failed to copy info hash: {error}", "Failed to deselect all files": "Failed to deselect all files", "Failed to deselect files": "Failed to deselect files", "Failed to deselect files: {error}": "Failed to deselect files: {error}", "Failed to disable io_uring: %s": "Failed to disable io_uring: %s", "Failed to discover NAT": "Impossible de découvrir NAT", "Failed to enable io_uring: %s": "Failed to enable io_uring: %s", "Failed to force start all torrents": "Failed to force start all torrents", "Failed to force start torrent": "Failed to force start torrent", "Failed to generate .tonic file": "Failed to generate .tonic file", "Failed to generate tonic link": "Failed to generate tonic link", "Failed to get NAT status": "Failed to get NAT status", "Failed to get Xet cache info": "Failed to get Xet cache info", "Failed to get Xet stats": "Failed to get Xet stats", "Failed to get config: {error}": "Failed to get config: {error}", "Failed to get content": "Échec de l'obtention du contenu", "Failed to get metrics interval from config: %s": "Failed to get metrics interval from config: %s", "Failed to get peers": "Impossible d'obtenir des pairs", "Failed to get per-peer rate limit": "Failed to get per-peer rate limit", "Failed to get queue": "Échec de l'obtention de la file d'attente", "Failed to get stats": "Impossible d'obtenir les statistiques", "Failed to get sync mode": "Failed to get sync mode", "Failed to get sync status": "Failed to get sync status", "Failed to launch media player": "Failed to launch media player", "Failed to list aliases": "Échec de la liste des alias", "Failed to list allowlist": "Failed to list allowlist", "Failed to list files": "Échec de la liste des fichiers", "Failed to list scrape results": "Failed to list scrape results", "Failed to load DHT health data: {error}": "Failed to load DHT health data: {error}", "Failed to load filter file: {file_path}": "Failed to load filter file: {file_path}", "Failed to load global KPIs: {error}": "Failed to load global KPIs: {error}", "Failed to load peer quality distribution: {error}": "Failed to load peer quality distribution: {error}", "Failed to load piece selection metrics: {error}": "Failed to load piece selection metrics: {error}", "Failed to load swarm timeline: {error}": "Failed to load swarm timeline: {error}", "Failed to map port": "Échec du mappage du port", "Failed to move in queue": "Failed to move in queue", "Failed to parse config value: %s": "Failed to parse config value: %s", "Failed to pause all torrents": "Failed to pause all torrents", "Failed to pause torrent": "Failed to pause torrent", "Failed to pin content": "Échec de l'épinglage du contenu", "Failed to refresh PEX": "Échec de l'actualisation du PEX", "Failed to refresh checkpoint": "Failed to refresh checkpoint", "Failed to refresh mappings": "Failed to refresh mappings", "Failed to refresh media state: {error}": "Failed to refresh media state: {error}", "Failed to register torrent in session": "Failed to register torrent in session", "Failed to reload checkpoint": "Failed to reload checkpoint", "Failed to remove alias": "Échec de la suppression de l'alias", "Failed to remove from queue": "Failed to remove from queue", "Failed to remove peer from allowlist": "Failed to remove peer from allowlist", "Failed to remove tracker": "Failed to remove tracker", "Failed to remove tracker: {error}": "Failed to remove tracker: {error}", "Failed to resume all torrents": "Failed to resume all torrents", "Failed to resume torrent": "Failed to resume torrent", "Failed to save config: {error}": "Failed to save config: {error}", "Failed to save configuration to file: %s": "Failed to save configuration to file: %s", "Failed to scrape torrent": "Failed to scrape torrent", "Failed to select all files": "Failed to select all files", "Failed to select files": "Échec de la sélection des fichiers", "Failed to select files: {error}": "Failed to select files: {error}", "Failed to set DHT aggressive mode": "Failed to set DHT aggressive mode", "Failed to set DHT aggressive mode: {error}": "Failed to set DHT aggressive mode: {error}", "Failed to set alias": "Échec de la définition de l'alias", "Failed to set all peers rate limits": "Failed to set all peers rate limits", "Failed to set file priority": "Failed to set file priority", "Failed to set first piece priority: %s": "Failed to set first piece priority: %s", "Failed to set last piece priority: %s": "Failed to set last piece priority: %s", "Failed to set per-peer rate limit": "Failed to set per-peer rate limit", "Failed to set priority": "Échec de la définition de la priorité", "Failed to set priority: {error}": "Failed to set priority: {error}", "Failed to set sync mode": "Failed to set sync mode", "Failed to share folder": "Échec du partage du dossier", "Failed to sign WebSocket request: %s": "Failed to sign WebSocket request: %s", "Failed to sign request with Ed25519: %s": "Failed to sign request with Ed25519: %s", "Failed to start media stream": "Failed to start media stream", "Failed to start sync": "Échec du démarrage de la synchronisation", "Failed to stop daemon": "Impossible d'arrêter le démon", "Failed to stop media stream": "Failed to stop media stream", "Failed to unmap port": "Impossible de démapper le port", "Failed to unpin content": "Failed to unpin content", "Fair": "Équitable", "Fetching Metadata...": "Récupération des métadonnées...", "Fetching file list for selection. This may take a moment.": "Fetching file list for selection. This may take a moment.", "Field": "Champ", "File": "File", "File Browser": "Navigateur de fichiers", "File Browser - Data provider or executor not available": "File Browser - Data provider or executor not available", "File Browser - Error: {error}": "File Browser - Error: {error}", "File Browser - Select files to create torrents": "File Browser - Select files to create torrents", "File Explorer": "Explorateur de fichiers", "File Name": "File Name", "File must have .torrent extension: %s": "File must have .torrent extension: %s", "File not found: %s": "Fichier introuvable : %s", "File selection not available for this torrent": "File selection not available for this torrent", "File {number}": "Fichier {number}", "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}": "File: {name}\nPort: {port}\nBytes served: {bytes_served}\nClients: {clients}\nLast range: {start} - {end}\nReadable bytes: {available}\nLast error: {error}", "Files": "Files", "Files in torrent {hash}...": "Files in torrent {hash}...", "Files: {count}": "Fichiers : {count}", "Filter update failed": "Échec de la mise à jour du filtre", "Folder not found: {folder}": "Folder not found: {folder}", "Folder: {name}": "Dossier : {name}", "Force Announce": "Forcer l'annonce", "Force kill without graceful shutdown": "Force kill without graceful shutdown", "Found {count} potential issues": "Found {count} potential issues", "Full Path": "Chemin complet", "Full configuration editing requires navigating to the Global Config screen": "Full configuration editing requires navigating to the Global Config screen", "General": "Général", "General configuration - Data provider/Executor not available": "General configuration - Data provider/Executor not available", "Generate new API key": "Générer une nouvelle clé API", "Generated new API key for daemon": "Generated new API key for daemon", "Generating {format} torrent...": "Generating {format} torrent...", "GitHub Dark": "GitHub sombre", "Global": "Mondial", "Global Config": "Global Config", "Global Configuration": "Configuration globale", "Global Connected Peers": "Pairs mondiaux connectés", "Global KPIs": "KPI mondiaux", "Global KPIs data is unavailable in the current mode.": "Global KPIs data is unavailable in the current mode.", "Global Key Performance Indicators": "Global Key Performance Indicators", "Global Torrent Metrics": "Métriques mondiales des torrents", "Global config": "Configuration globale", "Global download limit (KiB/s)": "Global download limit (KiB/s)", "Global upload limit (KiB/s)": "Global upload limit (KiB/s)", "Good": "Bien", "Graceful shutdown timeout, forcing stop": "Graceful shutdown timeout, forcing stop", "Graphs": "Graphiques", "Gruvbox": "Gruvbox", "HTTP error checking daemon status at %s: %s (status %d)": "HTTP error checking daemon status at %s: %s (status %d)", "Hash Chunk Size": "Taille du morceau de hachage", "Hash verification workers": "Hash verification workers", "Health": "Santé", "Help screen": "Écran d'aide", "High": "Haut", "Historical trends": "Tendances historiques", "History": "History", "Host for web interface": "Hôte pour interface web", "ID": "ID", "IP": "IP", "IP Address": "Adresse IP", "IP Filter": "IP Filter", "IP filter not available": "IP filter not available", "IP:Port": "IP :Port", "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)": "IPCClient.get_daemon_pid: Checking pid_file=%s (home_dir=%s, exists=%s)", "IPFS": "IPFS", "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.": "IPFS Protocol Options:\n\nIPFS enables content-addressed storage and peer-to-peer content sharing.\nContent can be accessed via IPFS CID after download.", "IPFS management": "Gestion IPFS", "Idle": "Inactif", "Inactive": "Inactif", "Include effective runtime value from loaded config (file + env)": "Include effective runtime value from loaded config (file + env)", "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)": "Increase verbosity (-v: verbose, -vv: debug, -vvv: trace)", "Index": "Indice", "Info": "Informations", "Info Hash": "Info Hash", "Info Hashes": "Hachages d'informations", "Info hash copied to clipboard": "Info hash copied to clipboard", "Info hash: {hash}": "Hachage des informations : {hash}", "Initial Rate": "Tarif initial", "Initial send rate": "Taux d'envoi initial", "Interactive backup": "Interactive backup", "Invalid IP address: {error}": "Invalid IP address: {error}", "Invalid IP range: {ip_range}": "Invalid IP range: {ip_range}", "Invalid configuration after merge: {e}": "Invalid configuration after merge: {e}", "Invalid configuration: top-level must be an object": "Invalid configuration: top-level must be an object", "Invalid configuration: {e}": "Invalid configuration: {e}", "Invalid info hash format": "Invalid info hash format", "Invalid info hash format: %s": "Invalid info hash format: %s", "Invalid info hash format: {hash}": "Invalid info hash format: {hash}", "Invalid info hash length in magnet link": "Invalid info hash length in magnet link", "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu": "Invalid locale '{current_locale}' specified. Falling back to 'en'. Available locales: en, es, fr, hi, ur, fa, arc, ja, ko, zh, th, sw, ha, yo, eu", "Invalid magnet link - missing 'xt=urn:btih:' parameter": "Invalid magnet link - missing 'xt=urn:btih:' parameter", "Invalid magnet link format": "Invalid magnet link format", "Invalid magnet link format - must start with 'magnet:?'": "Invalid magnet link format - must start with 'magnet:?'", "Invalid peer selection": "Sélection de pairs invalide", "Invalid profile '{name}': {errors}": "Invalid profile '{name}': {errors}", "Invalid template '{name}': {errors}": "Invalid template '{name}': {errors}", "Invalid torrent file format": "Invalid torrent file format", "Invalid tracker URL format. Must start with http://, https://, or udp://": "Invalid tracker URL format. Must start with http://, https://, or udp://", "Invalid tracker selection": "Invalid tracker selection", "Key": "Key", "Key Bindings": "Raccourcis de touches", "Key not found: {key}": "Key not found: {key}", "Language": "Langue", "Last Error": "Dernière erreur", "Last Scrape": "Last Scrape", "Last Update": "Dernière mise à jour", "Last sample {age}": "Dernier échantillon {age}", "Latency": "Latence", "Leechers": "Leechers", "Leechers (Scrape)": "Leechers (Scrape)", "Light": "Lumière", "Light Mode": "Mode lumière", "List available locales": "Répertorier les paramètres régionaux disponibles", "Listen interface": "Interface d'écoute", "Listen port": "Port d'écoute", "Loading configuration...": "Loading configuration...", "Loading file list…": "Chargement de la liste des fichiers…", "Loading peer metrics...": "Loading peer metrics...", "Loading piece selection metrics...": "Loading piece selection metrics...", "Loading swarm timeline...": "Loading swarm timeline...", "Loading torrent information...": "Loading torrent information...", "Local Node Information": "Informations sur le nœud local", "Low": "Faible", "MIGRATED": "MIGRATED", "MMap cache size (MB)": "Taille du cache MMap (Mo)", "MTU": "MTU", "Magnet command: PID file check - exists=%s, path=%s": "Magnet command: PID file check - exists=%s, path=%s", "Magnet link must contain 'xt=urn:btih:' parameter": "Magnet link must contain 'xt=urn:btih:' parameter", "Magnet link must start with 'magnet:?'": "Magnet link must start with 'magnet:?'", "Max Rate": "Tarif maximum", "Max Retransmits": "Retransmissions maximales", "Max Window Size": "Taille maximale de la fenêtre", "Maximum": "Maximum", "Maximum UDP packet size": "Maximum UDP packet size", "Maximum block size (KiB)": "Maximum block size (KiB)", "Maximum download rate for this torrent": "Maximum download rate for this torrent", "Maximum global peers": "Nombre maximum de pairs mondiaux", "Maximum peers per torrent": "Maximum peers per torrent", "Maximum receive window size": "Maximum receive window size", "Maximum retransmission attempts": "Maximum retransmission attempts", "Maximum send rate": "Taux d'envoi maximum", "Maximum upload rate for this torrent": "Maximum upload rate for this torrent", "Media": "Médias", "Media Playback": "Lecture multimédia", "Media stream started.": "Le flux multimédia a démarré.", "Media stream stopped.": "Le flux multimédia s'est arrêté.", "Medium": "Moyen", "Memory": "Mémoire", "Menu": "Menu", "Metadata is loading. File selection will appear when available.": "Metadata is loading. File selection will appear when available.", "Metric": "Metric", "Metrics explorer": "Explorateur de métriques", "Metrics interval (s)": "Intervalle(s) de métriques", "Metrics interval: {interval}s": "Metrics interval: {interval}s", "Metrics port": "Port de métriques", "Migrating checkpoint format from {from_fmt} to {to_fmt}...": "Migrating checkpoint format from {from_fmt} to {to_fmt}...", "Migration complete": "Migration terminée", "Min Rate": "Tarif minimum", "Minimum block size (KiB)": "Minimum block size (KiB)", "Minimum send rate": "Taux d'envoi minimum", "Mode": "Mode", "Model '{model}' not found in Config": "Model '{model}' not found in Config", "Modified": "Modifié", "Monitoring": "Surveillance", "Monokai": "Monokaï", "N/A": "N / A", "NAT Management": "NAT Management", "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.": "NAT Traversal Options:\n\nNAT traversal (NAT-PMP/UPnP) automatically maps ports on your router.\nThis allows peers to connect to you directly, improving download speeds.", "NAT management": "Gestion des NAT", "Name": "Name", "Name: {name}": "Nom : {name}", "Navigation": "Navigation", "Navigation menu": "Menu de navigation", "Network": "Network", "Network Configuration": "Configuration réseau", "Network Optimization Recommendations": "Network Optimization Recommendations", "Network Performance": "Performances du réseau", "Network configuration (connections, timeouts, rate limits)": "Network configuration (connections, timeouts, rate limits)", "Network configuration - Data provider/Executor not available": "Network configuration - Data provider/Executor not available", "Network quality": "Qualité du réseau", "Network quality - Error: {error}": "Network quality - Error: {error}", "Never": "Jamais", "Next": "Suivant", "Next Step": "Étape suivante", "No DHT metrics per torrent yet.": "No DHT metrics per torrent yet.", "No PID file found, checking for daemon via _get_executor()": "No PID file found, checking for daemon via _get_executor()", "No access": "Pas d'accès", "No active alerts": "No active alerts", "No active stream to stop.": "No active stream to stop.", "No alert rules": "No alert rules", "No alert rules configured": "No alert rules configured", "No availability data": "Aucune donnée de disponibilité", "No backups found": "No backups found", "No cached results": "No cached results", "No checkpoint found": "Aucun point de contrôle trouvé", "No checkpoints": "No checkpoints", "No commands available": "Aucune commande disponible", "No config file to backup": "No config file to backup", "No configuration file to backup": "No configuration file to backup", "No daemon PID file found - daemon is not running": "No daemon PID file found - daemon is not running", "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s": "No daemon detected (PID file doesn't exist), creating local session. PID file path: %s", "No file selected": "Aucun fichier sélectionné", "No files to deselect": "Aucun fichier à désélectionner", "No files to select": "Aucun fichier à sélectionner", "No locales directory found": "No locales directory found", "No magnet URI provided": "Aucun URI d'aimant fourni", "No magnet URI provided for add_magnet operation.": "No magnet URI provided for add_magnet operation.", "No metrics available": "Aucune métrique disponible", "No peer quality data available": "No peer quality data available", "No peer selected": "Aucun homologue sélectionné", "No peers available": "Aucun pair disponible", "No peers connected": "No peers connected", "No per-torrent data available": "No per-torrent data available", "No pieces": "Pas de pièces", "No playable files": "Aucun fichier lisible", "No playable media files were detected for this torrent.": "No playable media files were detected for this torrent.", "No profiles available": "No profiles available", "No recent security events.": "No recent security events.", "No section selected for editing": "No section selected for editing", "No significant events detected.": "No significant events detected.", "No swarm activity captured for the selected window.": "No swarm activity captured for the selected window.", "No swarm samples": "Aucun échantillon d'essaim", "No templates available": "No templates available", "No torrent active": "No torrent active", "No torrent data loaded. Please go back to step 1.": "No torrent data loaded. Please go back to step 1.", "No torrent path or magnet provided": "No torrent path or magnet provided", "No torrent path or magnet provided for add_torrent operation.": "No torrent path or magnet provided for add_torrent operation.", "No torrents with DHT activity yet.": "No torrents with DHT activity yet.", "No torrents yet. Use 'add' to start downloading.": "No torrents yet. Use 'add' to start downloading.", "No tracker selected": "Aucun tracker sélectionné", "No trackers found": "Aucun tracker trouvé", "Node ID": "ID du nœud", "Node Information": "Informations sur le nœud", "Node information not available.": "Node information not available.", "Nodes/Q": "Nœuds/Q", "Nodes: {count}": "Nodes: {count}", "Non-Empty Buckets": "Seaux non vides", "Nord": "Nord", "Normal": "Normale", "Not available": "Not available", "Not configured": "Not configured", "Not enabled": "Non activé", "Not enabled in configuration": "Not enabled in configuration", "Not initialized": "Non initialisé", "Not supported": "Not supported", "Note": "Note", "Number of pieces to verify for integrity (0 = disable)": "Number of pieces to verify for integrity (0 = disable)", "OK": "OK", "OK (dry-run — configuration is valid)": "OK (dry-run — configuration is valid)", "OK (dry-run — merged configuration is valid)": "OK (dry-run — merged configuration is valid)", "One Dark": "Un sombre", "Only options in this top-level section (e.g. network)": "Only options in this top-level section (e.g. network)", "Only paths starting with this prefix": "Only paths starting with this prefix", "Open File": "Ouvrir le fichier", "Open Folder": "Ouvrir le dossier", "Open in VLC": "Ouvrir dans VLC", "Opened folder: {path}": "Dossier ouvert : {path}", "Opened stream in external player via {method}.": "Opened stream in external player via {method}.", "Operation not supported": "Operation not supported", "Optimistic unchoke interval (s)": "Optimistic unchoke interval (s)", "Option": "Option", "Others can join with: ccbt tonic sync \"{link}\" --output ": "Others can join with: ccbt tonic sync \"{link}\" --output ", "Output Directory": "Répertoire de sortie", "Output directory": "Répertoire de sortie", "Output directory (default: current directory)": "Output directory (default: current directory)", "Output directory not available": "Output directory not available", "Output file path": "Chemin du fichier de sortie", "Output format for the option catalog": "Output format for the option catalog", "Overall Efficiency": "Efficacité globale", "Overall Health": "Santé globale", "Override IPC server port": "Override IPC server port", "PEX interval (s)": "Intervalle(s) PEX", "PEX refresh failed: {error}": "PEX refresh failed: {error}", "PEX refresh requested": "Actualisation PEX demandée", "PEX: Failed": "PEX : Échec", "PEX: {status}": "PEX: {status}", "PID file contains invalid PID: %d, removing": "PID file contains invalid PID: %d, removing", "PID file contains invalid data: %r, removing": "PID file contains invalid data: %r, removing", "PID file is empty, removing": "PID file is empty, removing", "Parsing files and building file tree...": "Parsing files and building file tree...", "Parsing files and building hybrid metadata...": "Parsing files and building hybrid metadata...", "Patch file format (auto: infer from extension or try JSON then TOML)": "Patch file format (auto: infer from extension or try JSON then TOML)", "Patch must be a JSON/TOML object at the top level": "Patch must be a JSON/TOML object at the top level", "Path": "Chemin", "Path does not exist": "Le chemin n'existe pas", "Path is not a file: %s": "Path is not a file: %s", "Path or magnet://...": "Chemin ou aimant://...", "Path to config file": "Chemin d'accès au fichier de configuration", "Pause failed: {error}": "Échec de la pause : {error}", "Pause torrent": "Suspendre le torrent", "Paused": "En pause", "Paused {info_hash}…": "En pause {info_hash}…", "Peer": "Pair", "Peer Details": "Détails des pairs", "Peer Distribution": "Répartition entre pairs", "Peer Efficiency": "Efficacité par les pairs", "Peer Quality": "Qualité par les pairs", "Peer Quality Distribution": "Peer Quality Distribution", "Peer Selection": "Sélection par les pairs", "Peer banning not yet implemented. Selected peer: {ip}:{port}": "Peer banning not yet implemented. Selected peer: {ip}:{port}", "Peer distribution - Error: {error}": "Peer distribution - Error: {error}", "Peer not found": "Homologue introuvable", "Peer quality - Error: {error}": "Peer quality - Error: {error}", "Peer quality data is unavailable in the current mode.": "Peer quality data is unavailable in the current mode.", "Peer timeout (s)": "Délai d'expiration des pairs", "Peer {ip}:{port} banned": "Peer {ip}:{port} banned", "Peers": "Peers", "Peers Found": "Pairs trouvés", "Peers/Q": "Pairs/Q", "Per-Peer": "Par homologue", "Per-Peer tab - Data provider or executor not available": "Per-Peer tab - Data provider or executor not available", "Per-Torrent": "Par torrent", "Per-Torrent Config: {hash}...": "Per-Torrent Config: {hash}...", "Per-Torrent Configuration": "Per-Torrent Configuration", "Per-Torrent Configuration: {name}": "Per-Torrent Configuration: {name}", "Per-Torrent Quality Summary": "Per-Torrent Quality Summary", "Per-Torrent tab - Data provider or executor not available": "Per-Torrent tab - Data provider or executor not available", "Per-torrent DHT": "DHT par torrent", "Per-torrent configuration - Data provider/Executor or torrent not available": "Per-torrent configuration - Data provider/Executor or torrent not available", "Per-torrent configuration saved successfully": "Per-torrent configuration saved successfully", "Percentage": "Pourcentage", "Performance": "Performance", "Performance metrics": "Mesures de performances", "Performance metrics - Error: {error}": "Performance metrics - Error: {error}", "Permission denied": "Autorisation refusée", "Piece Selection Strategy": "Piece Selection Strategy", "Piece selection metrics are not available yet for this torrent.": "Piece selection metrics are not available yet for this torrent.", "Piece selection metrics are unavailable in the current mode.": "Piece selection metrics are unavailable in the current mode.", "Pieces": "Pieces", "Pieces Received": "Pièces reçues", "Pieces Served": "Morceaux servis", "Pin Content in IPFS:": "Épingler le contenu dans IPFS :", "Pipeline Rejections": "Rejets des pipelines", "Pipeline Utilization": "Utilisation des pipelines", "Please enter a torrent path or magnet link": "Please enter a torrent path or magnet link", "Please fix parse errors before saving": "Please fix parse errors before saving", "Please fix validation errors before saving": "Please fix validation errors before saving", "Please select a torrent first": "Please select a torrent first", "Poor": "Pauvre", "Port": "Port", "Port for web interface": "Port for web interface", "Port: {port}": "Port: {port}", "Port: {port}, STUN: {stun_count} server(s)": "Port: {port}, STUN: {stun_count} server(s)", "Prefer Protocol v2 when available": "Prefer Protocol v2 when available", "Prefer over TCP": "Préférer TCP", "Prefer uTP when both TCP and uTP are available": "Prefer uTP when both TCP and uTP are available", "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s": "Prefer v2: {prefer_v2} | Hybrid: {hybrid} | Timeout: {timeout}s", "Press Ctrl+C to stop the daemon": "Press Ctrl+C to stop the daemon", "Press Enter to configure this section": "Press Enter to configure this section", "Previous": "Précédent", "Previous Step": "Étape précédente", "Prioritize first piece": "Prioritize first piece", "Prioritize last piece": "Prioriser la dernière pièce", "Prioritized Pieces": "Pièces prioritaires", "Priority": "Priority", "Priority (0 = normal, 1 = high, -1 = low):": "Priority (0 = normal, 1 = high, -1 = low):", "Priority level": "Niveau de priorité", "Private": "Private", "Profile '{name}' not found": "Profile '{name}' not found", "Profile applied to {path}": "Profile applied to {path}", "Profile config written to {path}": "Profile config written to {path}", "Profile: {name}": "Profil : {name}", "Profiles": "Profiles", "Progress": "Progress", "Property": "Property", "Protocol v2 (BEP 52)": "Protocole v2 (BEP 52)", "Protocols (Ctrl+)": "Protocoles (Ctrl+)", "Provide a VALUE argument or use --value=... for values with spaces or JSON": "Provide a VALUE argument or use --value=... for values with spaces or JSON", "Proxy Config": "Proxy Config", "Proxy config": "Configuration du proxy", "Public key must be 32 bytes (64 hex characters)": "Public key must be 32 bytes (64 hex characters)", "PyYAML is required for YAML export": "PyYAML is required for YAML export", "PyYAML is required for YAML import": "PyYAML is required for YAML import", "PyYAML is required for YAML output": "PyYAML is required for YAML output", "PyYAML is required for YAML patches": "PyYAML is required for YAML patches", "Quality": "Qualité", "Quality Distribution": "Distribution de qualité", "Queries": "Requêtes", "Queries Received": "Requêtes reçues", "Queries Sent": "Requêtes envoyées", "Quick Add": "Quick Add", "Quick Add Torrent": "Ajout rapide d'un torrent", "Quick Stats": "Statistiques rapides", "Quick add torrent": "Ajout rapide d'un torrent", "Quit": "Quit", "RTT multiplier for retransmit timeout": "RTT multiplier for retransmit timeout", "Rainbow": "Arc-en-ciel", "Rate Limits (KiB/s)": "Limites de débit (Ko/s)", "Rate limit configuration (global and per-torrent)": "Rate limit configuration (global and per-torrent)", "Rate limits disabled": "Rate limits disabled", "Rate limits set to 1024 KiB/s": "Rate limits set to 1024 KiB/s", "Rates": "Tarifs", "Read IPC port %d from daemon config file (authoritative source)": "Read IPC port %d from daemon config file (authoritative source)", "Recent Security Events ({count})": "Recent Security Events ({count})", "Recommended Settings": "Paramètres recommandés", "Recommended Value": "Valeur recommandée", "Reconnect to peers from checkpoint": "Reconnect to peers from checkpoint", "Recovery & Pipeline Health": "Recovery & Pipeline Health", "Refresh": "Rafraîchir", "Refresh PEX": "Actualiser le PEX", "Refresh tracker state from checkpoint": "Refresh tracker state from checkpoint", "Rehash: Failed": "Répétition : échec", "Rehash: {status}": "Rehash: {status}", "Remaining chunks: {count}": "Remaining chunks: {count}", "Remove": "Retirer", "Remove Tracker": "Supprimer le traqueur", "Remove checkpoints older than N days": "Remove checkpoints older than N days", "Remove failed: {error}": "Remove failed: {error}", "Remove tracker not yet implemented. Selected tracker: {url}": "Remove tracker not yet implemented. Selected tracker: {url}", "Reputation Tracking": "Suivi de la réputation", "Request Efficiency": "Efficacité des demandes", "Request Latency": "Latence des demandes", "Request Success": "Demande réussie", "Request pipeline depth": "Request pipeline depth", "Required": "Requis", "Reset specific key only (otherwise resets all options)": "Reset specific key only (otherwise resets all options)", "Resource": "Ressource", "Resource Utilization": "Utilisation des ressources", "Responses Received": "Réponses reçues", "Restart Required": "Redémarrage requis", "Restart daemon now?": "Redémarrer le démon maintenant ?", "Restore complete": "Restauration terminée", "Restore failed": "La restauration a échoué", "Restoring checkpoint...": "Restoring checkpoint...", "Resume failed: {error}": "Resume failed: {error}", "Resume from checkpoint if available": "Resume from checkpoint if available", "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.": "Resume from checkpoint if available:\n\nIf enabled, the download will resume from the last checkpoint.", "Resume from checkpoint:": "Resume from checkpoint:", "Resume from checkpoint?": "Resume from checkpoint?", "Resume torrent": "Reprendre le torrent", "Resumed {info_hash}…": "Reprise {info_hash}…", "Resuming {name}": "Reprise de {name}", "Retransmit Timeout Factor": "Retransmit Timeout Factor", "Routing Table": "Table de routage", "Routing table statistics not available.": "Routing table statistics not available.", "Rule": "Rule", "Rule not found: {ip_range}": "Rule not found: {ip_range}", "Rule not found: {name}": "Rule not found: {name}", "Rules: {rules}, IPv4: {ipv4}, IPv6: {ipv6}, Blocks: {blocks}": "Rules: {rules}, IPv4: {ipv4}, IPv6: {ipv6}, Blocks: {blocks}", "Run additional system compatibility checks after model validation": "Run additional system compatibility checks after model validation", "Run in foreground (for debugging)": "Run in foreground (for debugging)", "Running": "Running", "SSL Config": "SSL Config", "SSL config": "Configuration SSL", "Save Config": "Enregistrer la configuration", "Save Configuration": "Enregistrer la configuration", "Save checkpoint after reset": "Save checkpoint after reset", "Save checkpoint immediately after setting option": "Save checkpoint immediately after setting option", "Saving torrent to {path}...": "Saving torrent to {path}...", "Scanning folder and calculating chunks...": "Scanning folder and calculating chunks...", "Schema written to {path}": "Schema written to {path}", "Scrape": "Gratter", "Scrape Count": "Nombre de grattages", "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.": "Scrape Options:\n\nScraping queries tracker statistics (seeders, leechers, completed downloads).\nAuto-scrape will automatically scrape the tracker when the torrent is added.", "Scrape Results": "Scrape Results", "Scrape results": "Résultats du grattage", "Scrape: Failed": "Grattement : échec", "Scrape: {status}": "Scrape: {status}", "Search torrents...": "Rechercher des torrents...", "Section": "Section", "Section '{section}' is not a configuration section": "Section '{section}' is not a configuration section", "Section '{section}' not found": "Section '{section}' not found", "Section not found: {section}": "Section not found: {section}", "Section: {section}": "Rubrique : {section}", "Security": "Sécurité", "Security Events": "Événements de sécurité", "Security Scan": "Security Scan", "Security Scan Status": "État de l'analyse de sécurité", "Security Statistics": "Statistiques de sécurité", "Security configuration - Data provider/Executor not available": "Security configuration - Data provider/Executor not available", "Security manager not available. Security scanning requires local session mode.": "Security manager not available. Security scanning requires local session mode.", "Security scan": "Analyse de sécurité", "Security scan completed. No issues detected.": "Security scan completed. No issues detected.", "Security scan completed. {blocked} blocked connections, {events} security events detected.": "Security scan completed. {blocked} blocked connections, {events} security events detected.", "Security scan is not available when connected to daemon.": "Security scan is not available when connected to daemon.", "Security settings (encryption, IP filtering, SSL)": "Security settings (encryption, IP filtering, SSL)", "Seeders": "Seeders", "Seeders (Scrape)": "Seeders (Scrape)", "Seeding": "Semis", "Seeds": "Graines", "Select": "Sélectionner", "Select All": "Sélectionner tout", "Select File Priority": "Sélectionnez la priorité du fichier", "Select Files to Download": "Select Files to Download", "Select Language": "Sélectionnez la langue", "Select Priority": "Sélectionnez la priorité", "Select Section": "Sélectionnez une section", "Select Theme": "Sélectionnez un thème", "Select a graph type to view": "Select a graph type to view", "Select a section to configure": "Select a section to configure", "Select a section to configure. Press Enter to edit, Escape to go back.": "Select a section to configure. Press Enter to edit, Escape to go back.", "Select a sub-tab to view configuration options": "Select a sub-tab to view configuration options", "Select a sub-tab to view torrents": "Select a sub-tab to view torrents", "Select a torrent and sub-tab to view details": "Select a torrent and sub-tab to view details", "Select a torrent insight tab": "Select a torrent insight tab", "Select a workflow tab": "Sélectionnez un onglet de flux de travail", "Select files to download": "Select files to download", "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all": "Select files to download and set priorities:\n Space: Toggle selection\n P: Change priority\n A: Select all\n D: Deselect all", "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)": "Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)", "Select folder": "Sélectionner un dossier", "Select playable file": "Sélectionnez le fichier lisible", "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.": "Select queue priority for this torrent:\n\nHigher priority torrents will be started first.", "Select torrent...": "Sélectionnez torrent...", "Selected": "Selected", "Selected {count} file(s)": "Selected {count} file(s)", "Session": "Session", "Set Limits": "Fixer des limites", "Set Priority": "Définir la priorité", "Set locale (e.g., 'en', 'es', 'fr')": "Set locale (e.g., 'en', 'es', 'fr')", "Set priority to {priority} for file": "Set priority to {priority} for file", "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.": "Set rate limits for this torrent:\n\nEnter 0 or leave empty for unlimited.", "Set value in global config file": "Set value in global config file", "Set value in project local ccbt.toml": "Set value in project local ccbt.toml", "Setting": "Paramètre", "Severity": "Severity", "Share Ratio": "Ratio de partage", "Share failed": "Échec du partage", "Shared Peers": "Pairs partagés", "Show checkpoints in specific format": "Show checkpoints in specific format", "Show specific key path (e.g. network.listen_port)": "Show specific key path (e.g. network.listen_port)", "Show specific section key path (e.g. network)": "Show specific section key path (e.g. network)", "Show what would be deleted without actually deleting": "Show what would be deleted without actually deleting", "Shutdown timeout in seconds": "Shutdown timeout in seconds", "Size": "Size", "Size: {size}": "Taille : {size}", "Skip & Continue": "Passer et continuer", "Skip confirmation prompt": "Skip confirmation prompt", "Skip daemon restart even if needed": "Skip daemon restart even if needed", "Skip waiting and select all files": "Skip waiting and select all files", "Snapshot failed: {error}": "Snapshot failed: {error}", "Snapshot saved to {path}": "Snapshot saved to {path}", "Socket Optimizations": "Optimisations des sockets", "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.": "Socket connection test to %s:%d failed (result=%d). Port may not be open or firewall blocking. Proceeding with HTTP check anyway.", "Socket manager not initialized": "Socket manager not initialized", "Socket receive buffer (KiB)": "Socket receive buffer (KiB)", "Socket send buffer (KiB)": "Socket send buffer (KiB)", "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.": "Socket test returned 10035 (WSAEWOULDBLOCK) on Windows for %s:%d. This may be a false positive - proceeding with HTTP check.", "Solarized Dark": "Sombre solarisé", "Solarized Light": "Lumière solarisée", "Source path does not exist: %s": "Source path does not exist: %s", "Speed Category": "Catégorie de vitesse", "Speeds": "Vitesses", "Start Stream": "Démarrer le flux", "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.": "Start a stream to expose a localhost HTTP URL for VLC or another external player. Native in-terminal video embedding is out of scope.", "Start daemon in background without waiting for completion (faster startup)": "Start daemon in background without waiting for completion (faster startup)", "Start interactive mode": "Start interactive mode", "Start the stream before opening VLC.": "Start the stream before opening VLC.", "Starting daemon...": "Démarrage du démon...", "Starting file verification...": "Starting file verification...", "State: stopped\nSelected file index: {index}": "State: stopped\nSelected file index: {index}", "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}": "State: {state}\nURL: {url}\nBuffer readiness: {buffer:.0%}", "Status: ": "Status: ", "Step {current}/{total}: {steps}": "Step {current}/{total}: {steps}", "Stop Stream": "Arrêter le flux", "Stopped": "Arrêté", "Stopping daemon for restart...": "Stopping daemon for restart...", "Stopping daemon...": "Arrêt du démon...", "Stopping daemon... ({elapsed:.1f}s)": "Stopping daemon... ({elapsed:.1f}s)", "Storage": "Stockage", "Storage Device Detection": "Storage Device Detection", "Storage Type": "Type de stockage", "Storage configuration - Data provider/Executor not available": "Storage configuration - Data provider/Executor not available", "Strategy": "Stratégie", "Stuck Pieces Recovered": "Stuck Pieces Recovered", "Submit": "Soumettre", "Success": "Succès", "Successful Requests": "Demandes réussies", "Summary": "Résumé", "Supported": "Supported", "Supported MVP playback targets include common audio/video files.": "Supported MVP playback targets include common audio/video files.", "Swarm Health": "Santé de l'essaim", "Swarm Timeline": "Chronologie de l'essaim", "Swarm health - Error: {error}": "Swarm health - Error: {error}", "Swarm timeline - Error: {error}": "Swarm timeline - Error: {error}", "System Capabilities": "System Capabilities", "System Capabilities Summary": "System Capabilities Summary", "System Efficiency": "Efficacité du système", "System Resources": "System Resources", "System recommendations:": "System recommendations:", "System resources": "Ressources système", "System resources - Error: {error}": "System resources - Error: {error}", "Template '{name}' not found": "Template '{name}' not found", "Template applied to {path}": "Template applied to {path}", "Template config written to {path}": "Template config written to {path}", "Template: {name}": "Modèle : {name}", "Templates": "Templates", "Templates: {templates}": "Templates: {templates}", "Textual Dark": "Textuel sombre", "Theme": "Thème", "Theme: {theme}": "Thème : {theme}", "This torrent has no files to select.": "This torrent has no files to select.", "This will modify your configuration file. Continue?": "This will modify your configuration file. Continue?", "Tier": "Étage", "Time": "Temps", "Timeline": "Chronologie", "Timeline data is unavailable in the current mode.": "Timeline data is unavailable in the current mode.", "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...": "Timeout checking daemon accessibility (attempt %d/%d, elapsed %.1fs), retrying in %.1fs...", "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)": "Timeout checking daemon accessibility after %d attempts (elapsed %.1fs)", "Timeout checking daemon status at %s (daemon may be starting up or overloaded)": "Timeout checking daemon status at %s (daemon may be starting up or overloaded)", "Timestamp": "Timestamp", "Tip: full option catalog and file merge → ": "Tip: full option catalog and file merge → ", "Toggle Dark/Light": "Basculer entre sombre et clair", "Tokyo Night": "La nuit de Tokyo", "Top 10 Peers by Quality": "Top 10 Peers by Quality", "Top profile entries:": "Principales entrées de profil :", "Torrent": "Torrent", "Torrent Config": "Torrent Config", "Torrent Control": "Contrôle des torrents", "Torrent Controls": "Contrôles torrent", "Torrent Controls - Data provider or executor not available": "Torrent Controls - Data provider or executor not available", "Torrent Controls - Error: {error}": "Torrent Controls - Error: {error}", "Torrent File Explorer": "Explorateur de fichiers torrent", "Torrent Information": "Informations sur le torrent", "Torrent Status": "Torrent Status", "Torrent config": "Configuration du torrent", "Torrent file is empty: %s": "Torrent file is empty: %s", "Torrent file not found": "Torrent file not found", "Torrent file not found: %s": "Torrent file not found: %s", "Torrent not found": "Torrent not found", "Torrent paused": "Torrent en pause", "Torrent priority": "Priorité torrent", "Torrent removed": "Torrent supprimé", "Torrent resumed": "Le torrent a repris", "Torrent saved to {path}": "Torrent saved to {path}", "Torrents": "Torrents", "Torrents tab - Data provider or executor not available": "Torrents tab - Data provider or executor not available", "Torrents with DHT": "Torrents avec DHT", "Torrents: {count}": "Torrents: {count}", "Total Buckets": "Nombre total de compartiments", "Total Connections": "Connexions totales", "Total Downloaded": "Total téléchargé", "Total Nodes": "Nombre total de nœuds", "Total Peers": "Total des pairs", "Total Peers: {total} | Active Peers: {active}": "Total Peers: {total} | Active Peers: {active}", "Total Queries": "Total des requêtes", "Total Requests": "Total des demandes", "Total Size": "Taille totale", "Total Uploaded": "Total téléchargé", "Total chunks: {count}": "Nombre total de morceaux : {count}", "Total queries": "Nombre total de requêtes", "Tracker": "Traqueur", "Tracker Error": "Erreur de suivi", "Tracker Scrape": "Tracker Scrape", "Tracker added: {url}": "Suivi ajouté : {url}", "Tracker announce interval (s)": "Tracker announce interval (s)", "Tracker removed: {url}": "Tracker removed: {url}", "Tracker scrape interval (s)": "Tracker scrape interval (s)", "Trackers": "Traqueurs", "Tracking {count} torrent(s) across {minutes} minute window": "Tracking {count} torrent(s) across {minutes} minute window", "Trend: {trend} ({delta:+.1f}pp)": "Trend: {trend} ({delta:+.1f}pp)", "Type": "Type", "UI refresh interval: {interval}s": "UI refresh interval: {interval}s", "URL": "URL", "Unavailable": "Indisponible", "Unchoke interval (s)": "Intervalle(s) de désétranglement", "Unexpected error checking daemon status at %s: %s": "Unexpected error checking daemon status at %s: %s", "Unknown": "Unknown", "Unknown error": "Erreur inconnue", "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.": "Unknown operation '{operation}' requested but daemon PID file exists. This should not happen - please report this as a bug.", "Unknown operation: %s": "Opération inconnue : %s", "Unknown subcommand": "Unknown subcommand", "Unknown subcommand: {sub}": "Unknown subcommand: {sub}", "Unlimited": "Illimité", "Up (B/s)": "Haut (B/s)", "Updated at {time}": "Mis à jour à {time}", "Updated config file with daemon configuration": "Updated config file with daemon configuration", "Upload Limit": "Limite de téléchargement", "Upload Limit (KiB/s):": "Limite de téléchargement (Kio/s) :", "Upload Rate": "Taux de téléchargement", "Upload Rate Limit (bytes/sec, 0 = unlimited):": "Upload Rate Limit (bytes/sec, 0 = unlimited):", "Upload Speed": "Upload Speed", "Upload limit (KiB/s, 0 = unlimited)": "Upload limit (KiB/s, 0 = unlimited)", "Upload:": "Télécharger:", "Uploaded": "Téléchargé", "Uploading": "Téléchargement", "Uptime": "Temps de disponibilité", "Uptime: {uptime:.1f}s": "Uptime: {uptime:.1f}s", "Usage": "Usage", "Usage: alerts list|list-active|add|remove|clear|load|save|test ...": "Usage: alerts list|list-active|add|remove|clear|load|save|test ...", "Usage: backup ": "Usage: backup ", "Usage: checkpoint list": "Usage: checkpoint list", "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema": "Usage: config [show|get|set|reload] ...\nShell: btbt config describe | apply | import | schema", "Usage: config get ": "Usage: config get ", "Usage: config set ": "Usage: config set ", "Usage: config_backup list|create [desc]|restore ": "Usage: config_backup list|create [desc]|restore ", "Usage: config_diff ": "Usage: config_diff ", "Usage: config_export