sonic-bloom 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sonic_bloom-0.1.0/.gitignore +28 -0
- sonic_bloom-0.1.0/LICENSE +21 -0
- sonic_bloom-0.1.0/PKG-INFO +70 -0
- sonic_bloom-0.1.0/README.md +35 -0
- sonic_bloom-0.1.0/pyproject.toml +47 -0
- sonic_bloom-0.1.0/sonic_bloom/__init__.py +0 -0
- sonic_bloom-0.1.0/sonic_bloom/__main__.py +26 -0
- sonic_bloom-0.1.0/sonic_bloom/agent.py +114 -0
- sonic_bloom-0.1.0/sonic_bloom/app.py +152 -0
- sonic_bloom-0.1.0/sonic_bloom/bridge/__init__.py +17 -0
- sonic_bloom-0.1.0/sonic_bloom/bridge/catalog.py +80 -0
- sonic_bloom-0.1.0/sonic_bloom/bridge/events.py +71 -0
- sonic_bloom-0.1.0/sonic_bloom/bridge/scripting_bridge.py +322 -0
- sonic_bloom-0.1.0/sonic_bloom/cli/__init__.py +117 -0
- sonic_bloom-0.1.0/sonic_bloom/cli/display.py +145 -0
- sonic_bloom-0.1.0/sonic_bloom/cli/selection.py +71 -0
- sonic_bloom-0.1.0/sonic_bloom/config.py +95 -0
- sonic_bloom-0.1.0/sonic_bloom/history.py +44 -0
- sonic_bloom-0.1.0/sonic_bloom/providers/__init__.py +57 -0
- sonic_bloom-0.1.0/sonic_bloom/providers/anthropic.py +82 -0
- sonic_bloom-0.1.0/sonic_bloom/providers/openai.py +202 -0
- sonic_bloom-0.1.0/sonic_bloom/soul/__init__.py +0 -0
- sonic_bloom-0.1.0/sonic_bloom/soul/manager.py +73 -0
- sonic_bloom-0.1.0/sonic_bloom/soul/prompts.py +62 -0
- sonic_bloom-0.1.0/sonic_bloom/tools/__init__.py +97 -0
- sonic_bloom-0.1.0/sonic_bloom/tools/music_control.py +79 -0
- sonic_bloom-0.1.0/sonic_bloom/tools/music_info.py +48 -0
- sonic_bloom-0.1.0/sonic_bloom/tools/music_playlists.py +43 -0
- sonic_bloom-0.1.0/sonic_bloom/tools/music_search.py +139 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Git
|
|
2
|
+
.gitignore
|
|
3
|
+
|
|
4
|
+
# Plan / docs
|
|
5
|
+
CLAUDE.md
|
|
6
|
+
PLAN.md
|
|
7
|
+
|
|
8
|
+
# Python
|
|
9
|
+
__pycache__/
|
|
10
|
+
*.py[cod]
|
|
11
|
+
*.egg-info/
|
|
12
|
+
dist/
|
|
13
|
+
build/
|
|
14
|
+
.eggs/
|
|
15
|
+
|
|
16
|
+
# Environment
|
|
17
|
+
.env
|
|
18
|
+
.venv/
|
|
19
|
+
venv/
|
|
20
|
+
|
|
21
|
+
# IDE
|
|
22
|
+
.vscode/
|
|
23
|
+
.idea/
|
|
24
|
+
*.swp
|
|
25
|
+
.DS_Store
|
|
26
|
+
|
|
27
|
+
# Keys
|
|
28
|
+
*.p8
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 James Wirth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: sonic-bloom
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A little terminal app that controls Apple Music on macOS via natural language
|
|
5
|
+
Project-URL: Homepage, https://github.com/James-Wirth/sonic-bloom
|
|
6
|
+
Project-URL: Repository, https://github.com/James-Wirth/sonic-bloom
|
|
7
|
+
Author: James Wirth
|
|
8
|
+
License: MIT
|
|
9
|
+
Keywords: apple-music,cli,llm,macos,terminal
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: MacOS
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Requires-Dist: anthropic>=0.75.0
|
|
21
|
+
Requires-Dist: cryptography>=42.0
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: pyjwt>=2.8.0
|
|
24
|
+
Requires-Dist: pyobjc-core>=10.0
|
|
25
|
+
Requires-Dist: pyobjc-framework-cocoa>=10.0
|
|
26
|
+
Requires-Dist: pyobjc-framework-mediaplayer>=10.0
|
|
27
|
+
Requires-Dist: pyobjc-framework-scriptingbridge>=10.0
|
|
28
|
+
Requires-Dist: python-dotenv>=1.0
|
|
29
|
+
Requires-Dist: rich>=13.0
|
|
30
|
+
Provides-Extra: all
|
|
31
|
+
Requires-Dist: openai>=1.0; extra == 'all'
|
|
32
|
+
Provides-Extra: openai
|
|
33
|
+
Requires-Dist: openai>=1.0; extra == 'openai'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# Sonic Bloom
|
|
37
|
+
|
|
38
|
+
A little terminal app that controls Apple Music on macOS via natural language.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
> Play something whilst I assemble this Ikea shelf
|
|
42
|
+
|
|
43
|
+
⠋ Searching iTunes...
|
|
44
|
+
|
|
45
|
+
? Pick your track:
|
|
46
|
+
› The Final Countdown – Europe
|
|
47
|
+
Lose Yourself – Eminem
|
|
48
|
+
Harder Better Faster Stronger – Daft Punk
|
|
49
|
+
|
|
50
|
+
Good choice.
|
|
51
|
+
|
|
52
|
+
╭ ▶ Now Playing ────────────────────────────╮
|
|
53
|
+
│ The Final Countdown — Europe │
|
|
54
|
+
│ The Final Countdown │
|
|
55
|
+
│ vol 75 · shuffle off · repeat off │
|
|
56
|
+
╰───────────────────────────────────────────╯
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Setup
|
|
60
|
+
|
|
61
|
+
Requires macOS, Python 3.11+, and Apple Music.
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
git clone https://github.com/jameswirth/sonic-bloom.git
|
|
65
|
+
cd sonic-bloom
|
|
66
|
+
pip install -e .
|
|
67
|
+
python -m sonic_bloom
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
You'll be prompted to choose a provider (Anthropic, OpenAI, or Ollama) and enter an API key.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Sonic Bloom
|
|
2
|
+
|
|
3
|
+
A little terminal app that controls Apple Music on macOS via natural language.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
> Play something whilst I assemble this Ikea shelf
|
|
7
|
+
|
|
8
|
+
⠋ Searching iTunes...
|
|
9
|
+
|
|
10
|
+
? Pick your track:
|
|
11
|
+
› The Final Countdown – Europe
|
|
12
|
+
Lose Yourself – Eminem
|
|
13
|
+
Harder Better Faster Stronger – Daft Punk
|
|
14
|
+
|
|
15
|
+
Good choice.
|
|
16
|
+
|
|
17
|
+
╭ ▶ Now Playing ────────────────────────────╮
|
|
18
|
+
│ The Final Countdown — Europe │
|
|
19
|
+
│ The Final Countdown │
|
|
20
|
+
│ vol 75 · shuffle off · repeat off │
|
|
21
|
+
╰───────────────────────────────────────────╯
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Setup
|
|
25
|
+
|
|
26
|
+
Requires macOS, Python 3.11+, and Apple Music.
|
|
27
|
+
|
|
28
|
+
```sh
|
|
29
|
+
git clone https://github.com/jameswirth/sonic-bloom.git
|
|
30
|
+
cd sonic-bloom
|
|
31
|
+
pip install -e .
|
|
32
|
+
python -m sonic_bloom
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You'll be prompted to choose a provider (Anthropic, OpenAI, or Ollama) and enter an API key.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling<1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "sonic-bloom"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A little terminal app that controls Apple Music on macOS via natural language"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "James Wirth" }]
|
|
13
|
+
keywords = ["apple-music", "macos", "llm", "terminal", "cli"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: End Users/Desktop",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: MacOS",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Programming Language :: Python :: 3.13",
|
|
23
|
+
"Topic :: Multimedia :: Sound/Audio",
|
|
24
|
+
]
|
|
25
|
+
dependencies = [
|
|
26
|
+
"anthropic>=0.75.0",
|
|
27
|
+
"rich>=13.0",
|
|
28
|
+
"httpx>=0.27.0",
|
|
29
|
+
"PyJWT>=2.8.0",
|
|
30
|
+
"cryptography>=42.0",
|
|
31
|
+
"python-dotenv>=1.0",
|
|
32
|
+
"pyobjc-core>=10.0",
|
|
33
|
+
"pyobjc-framework-Cocoa>=10.0",
|
|
34
|
+
"pyobjc-framework-ScriptingBridge>=10.0",
|
|
35
|
+
"pyobjc-framework-MediaPlayer>=10.0",
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
openai = ["openai>=1.0"]
|
|
40
|
+
all = ["openai>=1.0"]
|
|
41
|
+
|
|
42
|
+
[project.urls]
|
|
43
|
+
Homepage = "https://github.com/James-Wirth/sonic-bloom"
|
|
44
|
+
Repository = "https://github.com/James-Wirth/sonic-bloom"
|
|
45
|
+
|
|
46
|
+
[project.scripts]
|
|
47
|
+
sonic-bloom = "sonic_bloom.__main__:main"
|
|
File without changes
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Entry point for Sonic Bloom."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def main():
|
|
7
|
+
# Suppress PyObjC app icon in the dock
|
|
8
|
+
try:
|
|
9
|
+
from AppKit import NSApplication, NSApplicationActivationPolicyProhibited
|
|
10
|
+
NSApplication.sharedApplication().setActivationPolicy_(NSApplicationActivationPolicyProhibited)
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
from dotenv import load_dotenv
|
|
15
|
+
load_dotenv()
|
|
16
|
+
|
|
17
|
+
from sonic_bloom.app import SonicBloom
|
|
18
|
+
app = SonicBloom()
|
|
19
|
+
try:
|
|
20
|
+
app.run()
|
|
21
|
+
except KeyboardInterrupt:
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
if __name__ == "__main__":
|
|
26
|
+
main()
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Provider-agnostic streaming tool-use agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from sonic_bloom.providers import Provider, TurnResult
|
|
10
|
+
from sonic_bloom.tools import get_tools, execute
|
|
11
|
+
from sonic_bloom.soul.prompts import build_system
|
|
12
|
+
|
|
13
|
+
MAX_TOOL_ITERATIONS = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(slots=True)
|
|
17
|
+
class TextDelta:
|
|
18
|
+
text: str
|
|
19
|
+
|
|
20
|
+
@dataclass(slots=True)
|
|
21
|
+
class ToolStart:
|
|
22
|
+
name: str
|
|
23
|
+
|
|
24
|
+
@dataclass(slots=True)
|
|
25
|
+
class ToolEnd:
|
|
26
|
+
name: str
|
|
27
|
+
result: dict | None = None
|
|
28
|
+
error: str | None = None
|
|
29
|
+
|
|
30
|
+
@dataclass(slots=True)
|
|
31
|
+
class AskUser:
|
|
32
|
+
question: str
|
|
33
|
+
tool_call_id: str
|
|
34
|
+
options: list[str] | None = None
|
|
35
|
+
|
|
36
|
+
AgentEvent = TextDelta | ToolStart | ToolEnd | AskUser
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class MusicAgent:
|
|
40
|
+
"""Streaming tool-use agent backed by any Provider."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, provider: Provider, soul_content: str | None = None):
|
|
43
|
+
self._provider = provider
|
|
44
|
+
self._soul_content = soul_content
|
|
45
|
+
self._messages: list[dict] = []
|
|
46
|
+
|
|
47
|
+
def chat(self, user_input: str) -> Generator[AgentEvent, str | None, None]:
|
|
48
|
+
"""Send a message and yield events as they stream back.
|
|
49
|
+
|
|
50
|
+
Yields AgentEvent instances. When an AskUser event is yielded,
|
|
51
|
+
the caller must .send() the user's answer string to resume.
|
|
52
|
+
"""
|
|
53
|
+
self._messages.append({"role": "user", "content": user_input})
|
|
54
|
+
yield from self._run_turn()
|
|
55
|
+
|
|
56
|
+
def _run_turn(self) -> Generator[AgentEvent, str | None, None]:
|
|
57
|
+
system = build_system(self._soul_content)
|
|
58
|
+
tools = get_tools()
|
|
59
|
+
for _ in range(MAX_TOOL_ITERATIONS):
|
|
60
|
+
result: TurnResult = yield from self._wrap_stream(
|
|
61
|
+
self._provider.stream_turn(
|
|
62
|
+
messages=self._messages,
|
|
63
|
+
system=system,
|
|
64
|
+
tools=tools,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
self._messages.append({"role": "assistant", "content": result.content})
|
|
69
|
+
|
|
70
|
+
if result.stop_reason != "tool_use":
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
tool_results = []
|
|
74
|
+
for call in result.tool_calls:
|
|
75
|
+
if call.name == "ask_user":
|
|
76
|
+
answer = yield AskUser(
|
|
77
|
+
question=call.input.get("question", ""),
|
|
78
|
+
tool_call_id=call.id,
|
|
79
|
+
options=call.input.get("options"),
|
|
80
|
+
)
|
|
81
|
+
tool_results.append({
|
|
82
|
+
"type": "tool_result",
|
|
83
|
+
"tool_use_id": call.id,
|
|
84
|
+
"content": f"User answered: {answer or '(no answer)'}",
|
|
85
|
+
})
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
yield ToolStart(name=call.name)
|
|
89
|
+
try:
|
|
90
|
+
r = execute(call.name, call.input)
|
|
91
|
+
yield ToolEnd(name=call.name, result=r)
|
|
92
|
+
except Exception as e:
|
|
93
|
+
r = {"error": str(e)}
|
|
94
|
+
yield ToolEnd(name=call.name, error=str(e))
|
|
95
|
+
tool_results.append({
|
|
96
|
+
"type": "tool_result",
|
|
97
|
+
"tool_use_id": call.id,
|
|
98
|
+
"content": json.dumps(r, default=str),
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
self._messages.append({"role": "user", "content": tool_results})
|
|
102
|
+
|
|
103
|
+
def _wrap_stream(
|
|
104
|
+
self, gen: Generator[str, None, TurnResult],
|
|
105
|
+
) -> Generator[AgentEvent, str | None, TurnResult]:
|
|
106
|
+
"""Wrap a provider's str-yielding generator into AgentEvent yields."""
|
|
107
|
+
try:
|
|
108
|
+
while True:
|
|
109
|
+
yield TextDelta(next(gen))
|
|
110
|
+
except StopIteration as e:
|
|
111
|
+
return e.value
|
|
112
|
+
|
|
113
|
+
def reset(self):
|
|
114
|
+
self._messages.clear()
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Application lifecycle and dependency wiring."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
from sonic_bloom.agent import MusicAgent
|
|
8
|
+
from sonic_bloom.bridge import get_music
|
|
9
|
+
from sonic_bloom.bridge.events import MusicEventThread
|
|
10
|
+
from sonic_bloom.cli import SonicBloomCLI
|
|
11
|
+
from sonic_bloom.cli.selection import select
|
|
12
|
+
from sonic_bloom.config import CONFIG_DIR, CONFIG_FILE, Config, PROVIDER_DEFAULTS, _API_KEY_ENV
|
|
13
|
+
from sonic_bloom.providers import make_provider
|
|
14
|
+
from sonic_bloom.soul.manager import SoulManager
|
|
15
|
+
|
|
16
|
+
SOUL_UPDATE_INTERVAL = 10
|
|
17
|
+
|
|
18
|
+
BANNER = (
|
|
19
|
+
" ███████╗ ██████╗ ███╗ ██╗██╗ ██████╗\n"
|
|
20
|
+
" ██╔════╝██╔═══██╗████╗ ██║██║██╔════╝\n"
|
|
21
|
+
" ███████╗██║ ██║██╔██╗ ██║██║██║ \n"
|
|
22
|
+
" ╚════██║██║ ██║██║╚██╗██║██║██║ \n"
|
|
23
|
+
" ███████║╚██████╔╝██║ ╚████║██║╚██████╗\n"
|
|
24
|
+
" ╚══════╝ ╚═════╝ ╚═╝ ╚═══╝╚═╝ ╚═════╝\n"
|
|
25
|
+
" ██████╗ ██╗ ██████╗ ██████╗ ███╗ ███╗\n"
|
|
26
|
+
" ██╔══██╗██║ ██╔═══██╗██╔═══██╗████╗ ████║\n"
|
|
27
|
+
" ██████╔╝██║ ██║ ██║██║ ██║██╔████╔██║\n"
|
|
28
|
+
" ██╔══██╗██║ ██║ ██║██║ ██║██║╚██╔╝██║\n"
|
|
29
|
+
" ██████╔╝███████╗╚██████╔╝╚██████╔╝██║ ╚═╝ ██║\n"
|
|
30
|
+
" ╚═════╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SonicBloom:
|
|
35
|
+
def __init__(self):
|
|
36
|
+
self.console = Console()
|
|
37
|
+
self.config = Config.load()
|
|
38
|
+
self.soul = SoulManager()
|
|
39
|
+
|
|
40
|
+
def run(self):
|
|
41
|
+
self.console.print()
|
|
42
|
+
self.console.print(f"[bold #5b4a9e]{BANNER}[/]")
|
|
43
|
+
self.console.print(" [dim]AI music assistant for Apple Music[/]")
|
|
44
|
+
self.console.print()
|
|
45
|
+
|
|
46
|
+
self._check_music_app()
|
|
47
|
+
soul_content = self.soul.read()
|
|
48
|
+
|
|
49
|
+
provider = self._make_provider()
|
|
50
|
+
if not provider:
|
|
51
|
+
return
|
|
52
|
+
agent = MusicAgent(provider=provider, soul_content=soul_content)
|
|
53
|
+
|
|
54
|
+
event_thread = MusicEventThread()
|
|
55
|
+
event_thread.start()
|
|
56
|
+
|
|
57
|
+
cli = SonicBloomCLI(self.console, agent, event_thread.queue)
|
|
58
|
+
try:
|
|
59
|
+
cli.loop()
|
|
60
|
+
except (KeyboardInterrupt, EOFError):
|
|
61
|
+
pass
|
|
62
|
+
finally:
|
|
63
|
+
self._maybe_update_soul(cli, provider, force=True)
|
|
64
|
+
self.console.print("\n [dim]Goodbye.[/]\n")
|
|
65
|
+
event_thread.stop()
|
|
66
|
+
|
|
67
|
+
def _check_music_app(self):
|
|
68
|
+
try:
|
|
69
|
+
m = get_music()
|
|
70
|
+
if not m.is_running:
|
|
71
|
+
self.console.print(" [yellow]Music.app is not running. Starting it...[/]")
|
|
72
|
+
m.activate()
|
|
73
|
+
except Exception:
|
|
74
|
+
self.console.print(" [yellow]Could not check Music.app status.[/]")
|
|
75
|
+
|
|
76
|
+
def _make_provider(self):
|
|
77
|
+
if not self.config.api_key and self.config.provider != "ollama":
|
|
78
|
+
if not self._setup():
|
|
79
|
+
return None
|
|
80
|
+
if self.config.provider == "ollama" and not self._check_ollama():
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return make_provider(self.config)
|
|
84
|
+
except Exception as e:
|
|
85
|
+
self.console.print(f" [red]Could not initialize {self.config.provider}: {e}[/]")
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
def _setup(self) -> bool:
|
|
89
|
+
"""First-run setup: choose provider and enter API key."""
|
|
90
|
+
self.console.print(" [bold]First-run setup[/]")
|
|
91
|
+
|
|
92
|
+
providers = list(PROVIDER_DEFAULTS.keys())
|
|
93
|
+
provider = select(self.console, "Choose a provider:", providers)
|
|
94
|
+
|
|
95
|
+
if provider == "ollama":
|
|
96
|
+
self._write_config(provider)
|
|
97
|
+
self.config = Config.load()
|
|
98
|
+
return True
|
|
99
|
+
|
|
100
|
+
env_var = _API_KEY_ENV.get(provider, "API_KEY")
|
|
101
|
+
self.console.print(f" [dim]You can also set [bold]{env_var}[/] instead.[/]\n")
|
|
102
|
+
try:
|
|
103
|
+
key = self.console.input(" API key: ", password=True).strip()
|
|
104
|
+
except (KeyboardInterrupt, EOFError):
|
|
105
|
+
self.console.print()
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
if not key:
|
|
109
|
+
self.console.print(" [red]No API key provided.[/]\n")
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
self._write_config(provider, key)
|
|
113
|
+
self.config = Config.load()
|
|
114
|
+
self.console.print(" [green]Config saved.[/]\n")
|
|
115
|
+
return True
|
|
116
|
+
|
|
117
|
+
def _write_config(self, provider: str, api_key: str | None = None):
|
|
118
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
lines = [f'provider = "{provider}"']
|
|
120
|
+
if api_key:
|
|
121
|
+
lines.append(f"\n[{provider}]")
|
|
122
|
+
lines.append(f'api_key = "{api_key}"')
|
|
123
|
+
if CONFIG_FILE.exists():
|
|
124
|
+
existing = CONFIG_FILE.read_text()
|
|
125
|
+
if existing.strip():
|
|
126
|
+
lines = [existing.rstrip(), ""] + lines
|
|
127
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
128
|
+
|
|
129
|
+
def _check_ollama(self) -> bool:
|
|
130
|
+
import httpx
|
|
131
|
+
base = self.config.base_url or "http://localhost:11434"
|
|
132
|
+
try:
|
|
133
|
+
httpx.get(f"{base}/api/tags", timeout=3)
|
|
134
|
+
return True
|
|
135
|
+
except httpx.ConnectError:
|
|
136
|
+
self.console.print(f" [red]Cannot connect to Ollama at {base}.[/]")
|
|
137
|
+
self.console.print(" [dim]Start Ollama with: ollama serve[/]\n")
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
def _maybe_update_soul(self, cli: SonicBloomCLI, provider, force: bool = False):
|
|
141
|
+
if not cli.interaction_log:
|
|
142
|
+
return
|
|
143
|
+
if not force and cli.interaction_count % SOUL_UPDATE_INTERVAL != 0:
|
|
144
|
+
return
|
|
145
|
+
try:
|
|
146
|
+
self.soul.update(
|
|
147
|
+
"\n".join(cli.interaction_log),
|
|
148
|
+
provider.simple_completion,
|
|
149
|
+
)
|
|
150
|
+
cli.interaction_log.clear()
|
|
151
|
+
except Exception:
|
|
152
|
+
self.console.print(" [dim]Could not update preferences.[/]")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Apple Music bridge — lazy initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from sonic_bloom.bridge.scripting_bridge import MusicApp
|
|
6
|
+
|
|
7
|
+
_music: MusicApp | None = None
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def get_music() -> MusicApp:
|
|
11
|
+
"""Get the MusicApp singleton, creating it on first call."""
|
|
12
|
+
global _music
|
|
13
|
+
if _music is None:
|
|
14
|
+
_music = MusicApp()
|
|
15
|
+
elif not _music.is_running:
|
|
16
|
+
_music = MusicApp()
|
|
17
|
+
return _music
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Apple Music REST API client for catalog search."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
import jwt
|
|
10
|
+
|
|
11
|
+
_SEARCH_URL = "https://api.music.apple.com/v1/catalog/{storefront}/search"
|
|
12
|
+
_GET_URL = "https://api.music.apple.com/v1/catalog/{storefront}/songs/{id}"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CatalogClient:
|
|
16
|
+
"""Apple Music catalog API client with JWT auth."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, key_id: str, team_id: str, key_path: Path, storefront: str = "us"):
|
|
19
|
+
self._key_id = key_id
|
|
20
|
+
self._team_id = team_id
|
|
21
|
+
self._key_path = key_path
|
|
22
|
+
self._storefront = storefront
|
|
23
|
+
self._token: str | None = None
|
|
24
|
+
self._token_expiry: float = 0
|
|
25
|
+
self._client = httpx.Client(timeout=10)
|
|
26
|
+
|
|
27
|
+
def search(self, query: str, limit: int = 5) -> list[dict]:
|
|
28
|
+
url = _SEARCH_URL.format(storefront=self._storefront)
|
|
29
|
+
resp = self._client.get(
|
|
30
|
+
url,
|
|
31
|
+
headers=self._auth_headers(),
|
|
32
|
+
params={"term": query, "types": "songs", "limit": limit},
|
|
33
|
+
)
|
|
34
|
+
resp.raise_for_status()
|
|
35
|
+
data = resp.json()
|
|
36
|
+
songs = data.get("results", {}).get("songs", {}).get("data", [])
|
|
37
|
+
return [self._parse_song(s) for s in songs]
|
|
38
|
+
|
|
39
|
+
def get_song(self, song_id: str) -> dict | None:
|
|
40
|
+
url = _GET_URL.format(storefront=self._storefront, id=song_id)
|
|
41
|
+
resp = self._client.get(url, headers=self._auth_headers())
|
|
42
|
+
if resp.status_code == 404:
|
|
43
|
+
return None
|
|
44
|
+
resp.raise_for_status()
|
|
45
|
+
data = resp.json()
|
|
46
|
+
songs = data.get("data", [])
|
|
47
|
+
return self._parse_song(songs[0]) if songs else None
|
|
48
|
+
|
|
49
|
+
def _auth_headers(self) -> dict:
|
|
50
|
+
return {"Authorization": f"Bearer {self._get_token()}"}
|
|
51
|
+
|
|
52
|
+
def _get_token(self) -> str:
|
|
53
|
+
if self._token and time.time() < self._token_expiry:
|
|
54
|
+
return self._token
|
|
55
|
+
private_key = self._key_path.read_text()
|
|
56
|
+
now = int(time.time())
|
|
57
|
+
payload = {
|
|
58
|
+
"iss": self._team_id,
|
|
59
|
+
"iat": now,
|
|
60
|
+
"exp": now + 15777000,
|
|
61
|
+
}
|
|
62
|
+
self._token = jwt.encode(
|
|
63
|
+
payload, private_key, algorithm="ES256",
|
|
64
|
+
headers={"kid": self._key_id},
|
|
65
|
+
)
|
|
66
|
+
self._token_expiry = now + 15777000 - 60
|
|
67
|
+
return self._token
|
|
68
|
+
|
|
69
|
+
@staticmethod
|
|
70
|
+
def _parse_song(song: dict) -> dict:
|
|
71
|
+
attrs = song.get("attributes", {})
|
|
72
|
+
return {
|
|
73
|
+
"store_id": song.get("id", ""),
|
|
74
|
+
"name": attrs.get("name", ""),
|
|
75
|
+
"artist": attrs.get("artistName", ""),
|
|
76
|
+
"album": attrs.get("albumName", ""),
|
|
77
|
+
"duration_ms": attrs.get("durationInMillis", 0),
|
|
78
|
+
"genre": (attrs.get("genreNames") or [""])[0],
|
|
79
|
+
"url": attrs.get("url", ""),
|
|
80
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Real-time Music.app notifications via NSDistributedNotificationCenter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import queue
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
import objc
|
|
10
|
+
from Foundation import NSObject, NSRunLoop, NSDate, NSDistributedNotificationCenter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True, slots=True)
|
|
14
|
+
class MusicEvent:
|
|
15
|
+
state: str
|
|
16
|
+
name: str | None = None
|
|
17
|
+
artist: str | None = None
|
|
18
|
+
album: str | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _Observer(NSObject):
|
|
22
|
+
@objc.python_method
|
|
23
|
+
def initWithQueue_(self, q: queue.Queue[MusicEvent]):
|
|
24
|
+
self = objc.super(_Observer, self).init()
|
|
25
|
+
if self is None:
|
|
26
|
+
return None
|
|
27
|
+
self._queue = q
|
|
28
|
+
return self
|
|
29
|
+
|
|
30
|
+
def handleNotification_(self, notification):
|
|
31
|
+
info = notification.userInfo()
|
|
32
|
+
if not info:
|
|
33
|
+
return
|
|
34
|
+
state = str(info.get("Player State", ""))
|
|
35
|
+
name = info.get("Name")
|
|
36
|
+
artist = info.get("Artist")
|
|
37
|
+
album = info.get("Album")
|
|
38
|
+
self._queue.put(MusicEvent(
|
|
39
|
+
state=state,
|
|
40
|
+
name=str(name) if name else None,
|
|
41
|
+
artist=str(artist) if artist else None,
|
|
42
|
+
album=str(album) if album else None,
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MusicEventThread:
|
|
47
|
+
"""Background thread listening for Music.app playback notifications."""
|
|
48
|
+
|
|
49
|
+
def __init__(self):
|
|
50
|
+
self.queue: queue.Queue[MusicEvent] = queue.Queue()
|
|
51
|
+
self._thread: threading.Thread | None = None
|
|
52
|
+
self._stop_event = threading.Event()
|
|
53
|
+
|
|
54
|
+
def start(self):
|
|
55
|
+
self._thread = threading.Thread(target=self._run, daemon=True)
|
|
56
|
+
self._thread.start()
|
|
57
|
+
|
|
58
|
+
def stop(self):
|
|
59
|
+
self._stop_event.set()
|
|
60
|
+
|
|
61
|
+
def _run(self):
|
|
62
|
+
observer = _Observer.alloc().initWithQueue_(self.queue)
|
|
63
|
+
center = NSDistributedNotificationCenter.defaultCenter()
|
|
64
|
+
center.addObserver_selector_name_object_(
|
|
65
|
+
observer, objc.selector(observer.handleNotification_, signature=b"v@:@"),
|
|
66
|
+
"com.apple.Music.playerInfo", None,
|
|
67
|
+
)
|
|
68
|
+
loop = NSRunLoop.currentRunLoop()
|
|
69
|
+
while not self._stop_event.is_set():
|
|
70
|
+
loop.runMode_beforeDate_("NSDefaultRunLoopMode", NSDate.dateWithTimeIntervalSinceNow_(0.5))
|
|
71
|
+
center.removeObserver_(observer)
|