Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
ea3cad3
adds docs fixes , compatibility fixes , lint , ci , precommit improve…
Jan 2, 2026
31092da
adds docs fixes , compatibility fixes , lint , ci , precommit improve…
Jan 2, 2026
944ecc5
adds docs fixes , compatibility fixes , lint , ci , precommit improve…
Jan 2, 2026
06457a5
solves failing tests and timeouts, adds testing fixtures
Jan 3, 2026
196de0b
adds docs fixes , compatibility fixes , lint , ci , precommit improve…
Jan 2, 2026
55e7a00
solves failing tests and timeouts, adds testing fixtures
Jan 3, 2026
7729df5
adds rebase
Jan 3, 2026
732f4fb
adds docs fixes , compatibility fixes , lint , ci , precommit improve…
Jan 3, 2026
a707c0d
adds cheaper ci and adds lints
Jan 3, 2026
4fb33c0
adds streaming media / vlc , adds xet implementation , adds bugfix dh…
Mar 15, 2026
6373527
Merge branch 'dev' into addsci
Josephrp Mar 15, 2026
753659c
Update .gitignore
Josephrp Mar 15, 2026
f1c300f
Update .github/workflows/release.yml
Josephrp Mar 15, 2026
bd90370
solves review comments
Mar 16, 2026
032fb8d
solves merge conflict
Mar 16, 2026
3395241
adds BEP 44 server , client , bugfix , swarm
Mar 16, 2026
e306db3
adds code quality
Mar 16, 2026
a371527
adds dht guards
Mar 16, 2026
dc6fbcf
solves review comments
Mar 16, 2026
d7fb37b
solves review comments
Mar 16, 2026
60ea277
solves review comments
Mar 16, 2026
4290693
adds bugfixes , xet, tests , lints
Mar 16, 2026
c9dde66
solves failing tests and some warnings
Mar 17, 2026
98b53c3
adds improvements
Mar 18, 2026
1a3adac
Merge branch 'dev' into addscli
Josephrp Mar 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
469 changes: 0 additions & 469 deletions .env copy

This file was deleted.

42 changes: 42 additions & 0 deletions .github/workflows/ci.yml
Original file line numberDiff line numberDiff line change
Expand Up@@ -73,3 +73,45 @@ jobs:
- name: Run Ty type checking
run: |
uv run ty check --config-file=dev/ty.toml --output-format=concise

i18n:
name: i18n
runs-on: ubuntu-latest
continue-on-error: true
environment: approval-required
permissions:
contents: read
actions: read
pull-requests: read
steps:
- uses: actions/checkout@v4

- name: Install UV
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: |
uv sync --dev

- name: Extract translatable strings
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

- name: Check translation completeness
run: |
uv run python -m ccbt.i18n.scripts.check_completeness
61 changes: 61 additions & 0 deletions .github/workflows/i18n-manual.yml
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
# Manual i18n pipeline: extract, update, validate, coverage, completeness, compile.
# Run from Actions tab (workflow_dispatch) to validate translations on main or current branch.
name: i18n (manual)

on:
workflow_dispatch:
inputs:
branch:
description: 'Branch to run on (default: main)'
required: false
default: 'main'

jobs:
i18n-full:
name: i18n full pipeline
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.branch || 'main' }}

- name: Install UV
uses: astral-sh/setup-uv@v4
with:
version: "latest"

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Install dependencies
run: uv sync --dev

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

- name: Check translation completeness
run: uv run python -m ccbt.i18n.scripts.check_completeness --output completeness_report.txt

- name: Upload completeness report
uses: actions/upload-artifact@v4
with:
name: i18n-completeness-report
path: completeness_report.txt

- name: Compile .mo files
run: uv run python -m ccbt.i18n.scripts.compile_all
continue-on-error: true
1 change: 1 addition & 0 deletions .gitignore
Original file line numberDiff line numberDiff line change
Expand Up@@ -344,6 +344,7 @@ docs/_build/
*.zip

# Local configuration
.env copy
.env.local
.env.development.local
.env.test.local
Expand Down
1 change: 1 addition & 0 deletions ccbt.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -330,6 +330,7 @@ map_xet_multicast_port = false
[daemon]
ipc_host = "127.0.0.1"
ipc_port = 64130
api_key = "63db67e447a552c3541457967f7b3f4bedbcda97b57fa750f6e35831553043d1"

[webtorrent]
enable_webtorrent = true
Expand Down
21 changes: 20 additions & 1 deletion ccbt/cli/daemon_commands.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -428,7 +428,14 @@ def run_splash():
splash_thread.start()

try:
pid = daemon_manager.start(foreground=False)
# Pass config file to daemon so it uses the same config as CLI
extra_args: list[str] = []
if config_manager.config_file and config_manager.config_file.exists():
extra_args.extend(["--config", str(config_manager.config_file)])
pid = daemon_manager.start(
foreground=False,
extra_args=extra_args if extra_args else None,
)

# Give the process a moment to initialize before checking
time.sleep(0.2)
Expand DownExpand Up@@ -549,6 +556,18 @@ def run_splash():

except RuntimeError as e:
console.print(_("[red]✗[/red] Failed to start daemon: {e}").format(e=e))
# Point user to log file and foreground for debugging
log_file = daemon_manager.state_dir / "daemon_startup.log"
if log_file.exists():
console.print(
_("[dim]See daemon log: {path}[/dim]").format(path=log_file)
)
console.print(
_(
"[yellow]To see errors in the terminal, run:[/yellow] "
"[dim]uv run btbt daemon start --foreground[/dim]"
)
)
raise click.Abort from e


Expand Down
188 changes: 169 additions & 19 deletions ccbt/cli/downloads.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,6 +22,148 @@
from ccbt.session.session import AsyncSessionManager


def _format_size_cli(bytes_count: int) -> str:
"""Format bytes as human-readable size for CLI output."""
size = float(bytes_count)
for unit in ["B", "KiB", "MiB", "GiB", "TiB"]:
if size < 1024.0:
return f"{size:.2f} {unit}"
size /= 1024.0
return f"{size:.2f} PiB"


async def run_magnet_file_selection_step(
executor: Any,
info_hash_hex: str,
console: Console,
timeout: float = 120.0,
poll_interval: float = 2.5,
) -> None:
"""Wait for metadata, show file list, prompt for selection, apply file.select.

Polls file.list until files are available or timeout. Then prints a table,
prompts for [a]ll, [n]one, or comma/range indices, and calls file.select
(or file.deselect for none).

Args:
executor: UnifiedCommandExecutor (or any with execute(command, **kwargs))
info_hash_hex: Torrent info hash (hex)
console: Rich console for output
timeout: Max seconds to wait for file list
poll_interval: Seconds between file.list polls

"""
from rich.prompt import Prompt
from rich.table import Table

deadline = asyncio.get_event_loop().time() + timeout
file_items: list[Any] = []
while asyncio.get_event_loop().time() < deadline:
result = await executor.execute("file.list", info_hash=info_hash_hex)
if result.success and result.data:
raw = result.data.get("files")
if hasattr(raw, "files"):
file_items = list(raw.files)
elif isinstance(raw, list):
file_items = raw
else:
file_items = []
if file_items:
break
await asyncio.sleep(poll_interval)

if not file_items:
console.print(
_(
"[yellow]No file list available within {timeout}s, continuing with default selection.[/yellow]"
).format(timeout=timeout)
)
return

# Build table
table = Table(title=_("Files in torrent {hash}...").format(hash=info_hash_hex[:16]))
table.add_column(_("Index"), style="cyan", justify="right")
table.add_column(_("Name"), style="green")
table.add_column(_("Size"), justify="right", style="magenta")
indices: list[int] = []
for item in file_items:
idx = getattr(item, "index", len(indices))
name = getattr(item, "name", getattr(item, "path", "?"))
size = getattr(item, "size", 0)
indices.append(idx)
table.add_row(str(idx), str(name), _format_size_cli(size))
console.print(table)

prompt_msg = _("Select files: [a]ll, [n]one, or indices (e.g. 0,2-5)")
try:
choice = Prompt.ask(prompt_msg, default="a").strip().lower()
except Exception:
choice = "a"

if choice == "n":
# Deselect all: select no files (deselect all indices)
result = await executor.execute(
"file.deselect",
info_hash=info_hash_hex,
file_indices=indices,
)
if result.success:
console.print(_("[green]Deselected all files.[/green]"))
else:
console.print(
_("[yellow]Could not deselect: {error}[/yellow]").format(
error=result.error or _("Unknown error")
)
)
return

if choice == "a" or not choice:
selected = indices
else:
# Parse comma-separated indices and ranges (e.g. 0,2-5,8)
selected = []
for part in choice.split(","):
stripped = part.strip()
if "-" in stripped:
try:
a, b = stripped.split("-", 1)
lo, hi = int(a.strip()), int(b.strip())
for i in range(lo, hi + 1):
if i in indices:
selected.append(i)
except (ValueError, TypeError):
pass
else:
try:
i = int(stripped)
if i in indices:
selected.append(i)
except (ValueError, TypeError):
pass
selected = sorted(set(selected))

if not selected:
console.print(
_("[yellow]No valid indices, keeping default selection.[/yellow]")
)
return
result = await executor.execute(
"file.select",
info_hash=info_hash_hex,
file_indices=selected,
)
if result.success:
console.print(
_("[green]Selected {count} file(s).[/green]").format(count=len(selected))
)
else:
console.print(
_("[yellow]Select failed: {error}[/yellow]").format(
error=result.error or _("Unknown error")
)
)


async def start_interactive_download(
session: AsyncSessionManager,
torrent_data: dict[str, Any],
Expand DownExpand Up@@ -597,42 +739,50 @@ async def start_basic_magnet_download(
async def start_interactive_magnet_download(
session: AsyncSessionManager,
magnet_link: str,
info_hash_hex: str,
console: Console,
resume: bool = False,
output_dir: Optional[Any] = None,
) -> None:
"""Start an interactive magnet link download with user prompts.
"""Start interactive CLI for a magnet already added via executor.

The magnet must have been added with executor so add_magnet() ran and
magnet_info is set (BEP 53). This only runs the InteractiveCLI download
loop and optional file selection.

Args:
session: The session manager instance
magnet_link: Magnet URI string
magnet_link: Magnet URI string (for display / parse_magnet name)
info_hash_hex: Info hash from torrent.add result
console: Rich console for output
resume: Whether to resume from checkpoint
output_dir: Optional output directory (for display)

"""
cleanup_task = getattr(session, "_cleanup_task", None)
if cleanup_task is None:
console.print(_("[cyan]Initializing session components...[/cyan]"))
await session.start()

# Wait for session to be ready (best effort)
# Note: is_ready method may not exist on all session implementations

# Create executor with local adapter
adapter = LocalSessionAdapter(session)
executor = UnifiedCommandExecutor(adapter)

result = await executor.execute(
"torrent.add",
path_or_magnet=magnet_link,
resume=resume,
)
if not result.success:
raise RuntimeError(result.error or "Failed to add magnet link")

from ccbt.interface.terminal_dashboard import TerminalDashboard
from ccbt.core.magnet import parse_magnet

app = TerminalDashboard(session)
try:
app.run() # type: ignore[attr-defined]
except KeyboardInterrupt:
console.print(_("[yellow]Download interrupted by user[/yellow]"))
magnet_info = parse_magnet(magnet_link)
name = magnet_info.display_name or "Unknown"
except Exception:
name = "Unknown"

torrent_data = {
"name": name,
"info_hash": info_hash_hex,
"download_path": output_dir,
}
interactive_cli = InteractiveCLI(executor, adapter, console, session=session)
await interactive_cli.download_torrent(
torrent_data,
resume=resume,
already_added_info_hash=info_hash_hex,
)
2 changes: 1 addition & 1 deletion ccbt/cli/file_commands.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -70,7 +70,7 @@ async def _list_files() -> None:
result = await executor.execute("file.list", info_hash=info_hash)

if not result.success:
raise click.ClickException(result.error or "Failed to list files")
raise click.ClickException(result.error or _("Failed to list files"))

file_list = result.data["files"]

Expand Down
Loading