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.
Files changed (29) hide show
  1. sonic_bloom-0.1.0/.gitignore +28 -0
  2. sonic_bloom-0.1.0/LICENSE +21 -0
  3. sonic_bloom-0.1.0/PKG-INFO +70 -0
  4. sonic_bloom-0.1.0/README.md +35 -0
  5. sonic_bloom-0.1.0/pyproject.toml +47 -0
  6. sonic_bloom-0.1.0/sonic_bloom/__init__.py +0 -0
  7. sonic_bloom-0.1.0/sonic_bloom/__main__.py +26 -0
  8. sonic_bloom-0.1.0/sonic_bloom/agent.py +114 -0
  9. sonic_bloom-0.1.0/sonic_bloom/app.py +152 -0
  10. sonic_bloom-0.1.0/sonic_bloom/bridge/__init__.py +17 -0
  11. sonic_bloom-0.1.0/sonic_bloom/bridge/catalog.py +80 -0
  12. sonic_bloom-0.1.0/sonic_bloom/bridge/events.py +71 -0
  13. sonic_bloom-0.1.0/sonic_bloom/bridge/scripting_bridge.py +322 -0
  14. sonic_bloom-0.1.0/sonic_bloom/cli/__init__.py +117 -0
  15. sonic_bloom-0.1.0/sonic_bloom/cli/display.py +145 -0
  16. sonic_bloom-0.1.0/sonic_bloom/cli/selection.py +71 -0
  17. sonic_bloom-0.1.0/sonic_bloom/config.py +95 -0
  18. sonic_bloom-0.1.0/sonic_bloom/history.py +44 -0
  19. sonic_bloom-0.1.0/sonic_bloom/providers/__init__.py +57 -0
  20. sonic_bloom-0.1.0/sonic_bloom/providers/anthropic.py +82 -0
  21. sonic_bloom-0.1.0/sonic_bloom/providers/openai.py +202 -0
  22. sonic_bloom-0.1.0/sonic_bloom/soul/__init__.py +0 -0
  23. sonic_bloom-0.1.0/sonic_bloom/soul/manager.py +73 -0
  24. sonic_bloom-0.1.0/sonic_bloom/soul/prompts.py +62 -0
  25. sonic_bloom-0.1.0/sonic_bloom/tools/__init__.py +97 -0
  26. sonic_bloom-0.1.0/sonic_bloom/tools/music_control.py +79 -0
  27. sonic_bloom-0.1.0/sonic_bloom/tools/music_info.py +48 -0
  28. sonic_bloom-0.1.0/sonic_bloom/tools/music_playlists.py +43 -0
  29. 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)