Skip to content

Commit 9080e12

Browse files
authored
Merge pull request #6 from OpenAdaptAI/feat/wormhole-share
feat(share): add Magic Wormhole sharing for recordings
2 parents 871bcea + e7cfb1e commit 9080e12

File tree

3 files changed

+207
-1
lines changed

3 files changed

+207
-1
lines changed

openadapt_capture/cli.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,29 @@ def _save_transcript(
340340
print(f"[{mins}:{secs:05.2f}] {seg['text']}")
341341

342342

343+
def share(action: str, path_or_code: str, output_dir: str = ".") -> None:
344+
"""Share recordings via Magic Wormhole.
345+
346+
Args:
347+
action: Either "send" or "receive".
348+
path_or_code: Recording path (for send) or wormhole code (for receive).
349+
output_dir: Output directory for receive (default: current dir).
350+
351+
Examples:
352+
capture share send ./my_recording
353+
capture share receive 7-guitarist-revenge
354+
capture share receive 7-guitarist-revenge ./recordings
355+
"""
356+
from openadapt_capture.share import send, receive
357+
358+
if action == "send":
359+
send(path_or_code)
360+
elif action == "receive":
361+
receive(path_or_code, output_dir)
362+
else:
363+
print(f"Unknown action: {action}. Use 'send' or 'receive'.")
364+
365+
343366
def main() -> None:
344367
"""CLI entry point."""
345368
import fire
@@ -348,6 +371,7 @@ def main() -> None:
348371
"visualize": visualize,
349372
"info": info,
350373
"transcribe": transcribe,
374+
"share": share,
351375
})
352376

353377

openadapt_capture/share.py

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
"""Share recordings between computers using Magic Wormhole.
2+
3+
Usage:
4+
capture share send ./my_recording
5+
capture share receive 7-guitarist-revenge
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import shutil
11+
import subprocess
12+
import sys
13+
import tempfile
14+
from pathlib import Path
15+
from zipfile import ZIP_DEFLATED, ZipFile
16+
17+
18+
def _check_wormhole_installed() -> bool:
19+
"""Check if magic-wormhole is installed."""
20+
return shutil.which("wormhole") is not None
21+
22+
23+
def _install_wormhole() -> bool:
24+
"""Attempt to install magic-wormhole."""
25+
print("Installing magic-wormhole...")
26+
try:
27+
subprocess.run(
28+
[sys.executable, "-m", "pip", "install", "magic-wormhole"],
29+
check=True,
30+
capture_output=True,
31+
)
32+
print("✓ magic-wormhole installed")
33+
return True
34+
except subprocess.CalledProcessError as e:
35+
print(f"✗ Failed to install magic-wormhole: {e}")
36+
return False
37+
38+
39+
def _ensure_wormhole() -> bool:
40+
"""Ensure magic-wormhole is available, install if needed."""
41+
if _check_wormhole_installed():
42+
return True
43+
return _install_wormhole()
44+
45+
46+
def send(recording_dir: str) -> str | None:
47+
"""Send a recording via Magic Wormhole.
48+
49+
Args:
50+
recording_dir: Path to the recording directory.
51+
52+
Returns:
53+
The wormhole code if successful, None otherwise.
54+
"""
55+
recording_path = Path(recording_dir)
56+
57+
if not recording_path.exists():
58+
print(f"✗ Recording not found: {recording_path}")
59+
return None
60+
61+
if not recording_path.is_dir():
62+
print(f"✗ Not a directory: {recording_path}")
63+
return None
64+
65+
if not _ensure_wormhole():
66+
return None
67+
68+
# Create a temporary zip file
69+
zip_name = f"{recording_path.name}.zip"
70+
71+
with tempfile.TemporaryDirectory() as tmpdir:
72+
zip_path = Path(tmpdir) / zip_name
73+
74+
print(f"Compressing {recording_path.name}...")
75+
with ZipFile(zip_path, "w", ZIP_DEFLATED, compresslevel=6) as zf:
76+
for file in recording_path.rglob("*"):
77+
if file.is_file():
78+
arcname = file.relative_to(recording_path.parent)
79+
zf.write(file, arcname)
80+
81+
size_mb = zip_path.stat().st_size / (1024 * 1024)
82+
print(f"✓ Compressed to {size_mb:.1f} MB")
83+
84+
print("Sending via Magic Wormhole...")
85+
print("(Keep this window open until transfer completes)")
86+
print()
87+
88+
try:
89+
# Run wormhole send
90+
result = subprocess.run(
91+
["wormhole", "send", str(zip_path)],
92+
check=True,
93+
)
94+
return "sent" # Code is printed by wormhole itself
95+
except subprocess.CalledProcessError as e:
96+
print(f"✗ Wormhole send failed: {e}")
97+
return None
98+
except KeyboardInterrupt:
99+
print("\n✗ Cancelled")
100+
return None
101+
102+
103+
def receive(code: str, output_dir: str = ".") -> Path | None:
104+
"""Receive a recording via Magic Wormhole.
105+
106+
Args:
107+
code: The wormhole code (e.g., "7-guitarist-revenge").
108+
output_dir: Directory to save the recording (default: current dir).
109+
110+
Returns:
111+
Path to the received recording directory, or None on failure.
112+
"""
113+
if not _ensure_wormhole():
114+
return None
115+
116+
output_path = Path(output_dir)
117+
output_path.mkdir(parents=True, exist_ok=True)
118+
119+
with tempfile.TemporaryDirectory() as tmpdir:
120+
tmpdir = Path(tmpdir)
121+
122+
print(f"Receiving from wormhole code: {code}")
123+
124+
try:
125+
# Run wormhole receive
126+
result = subprocess.run(
127+
["wormhole", "receive", "--accept-file", "-o", str(tmpdir), code],
128+
check=True,
129+
)
130+
131+
# Find the received zip file
132+
zip_files = list(tmpdir.glob("*.zip"))
133+
if not zip_files:
134+
print("✗ No zip file received")
135+
return None
136+
137+
zip_path = zip_files[0]
138+
print(f"✓ Received {zip_path.name}")
139+
140+
# Extract
141+
print("Extracting...")
142+
with ZipFile(zip_path, "r") as zf:
143+
zf.extractall(output_path)
144+
145+
# Find the extracted directory
146+
extracted = [
147+
p for p in output_path.iterdir()
148+
if p.is_dir() and p.name != "__MACOSX"
149+
]
150+
151+
if extracted:
152+
recording_dir = extracted[0]
153+
print(f"✓ Saved to: {recording_dir}")
154+
return recording_dir
155+
else:
156+
print(f"✓ Extracted to: {output_path}")
157+
return output_path
158+
159+
except subprocess.CalledProcessError as e:
160+
print(f"✗ Wormhole receive failed: {e}")
161+
return None
162+
except KeyboardInterrupt:
163+
print("\n✗ Cancelled")
164+
return None
165+
166+
167+
def main() -> None:
168+
"""CLI entry point for share commands."""
169+
import fire
170+
fire.Fire({
171+
"send": send,
172+
"receive": receive,
173+
})
174+
175+
176+
if __name__ == "__main__":
177+
main()

pyproject.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,14 @@ privacy = [
5151
"openadapt-privacy>=0.1.0",
5252
]
5353

54+
# Sharing via Magic Wormhole
55+
share = [
56+
"magic-wormhole>=0.17.0",
57+
]
58+
5459
# Everything
5560
all = [
56-
"openadapt-capture[transcribe-fast,transcribe,privacy]",
61+
"openadapt-capture[transcribe-fast,transcribe,privacy,share]",
5762
]
5863

5964
dev = [

0 commit comments

Comments
 (0)