livepilot 1.14.1 → 1.16.0

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 (33) hide show
  1. package/CHANGELOG.md +176 -1
  2. package/README.md +6 -6
  3. package/m4l_device/LivePilot_Analyzer.amxd +0 -0
  4. package/mcp_server/__init__.py +1 -1
  5. package/mcp_server/atlas/device_atlas.json +91219 -7161
  6. package/mcp_server/atlas/tools.py +30 -2
  7. package/mcp_server/runtime/live_version.py +4 -2
  8. package/mcp_server/runtime/remote_commands.py +5 -0
  9. package/mcp_server/sample_engine/tools.py +692 -60
  10. package/mcp_server/splice_client/client.py +511 -65
  11. package/mcp_server/splice_client/http_bridge.py +361 -0
  12. package/mcp_server/splice_client/models.py +266 -2
  13. package/mcp_server/splice_client/quota.py +229 -0
  14. package/mcp_server/tools/_analyzer_engine/__init__.py +4 -0
  15. package/mcp_server/tools/_analyzer_engine/sample.py +73 -0
  16. package/mcp_server/tools/analyzer.py +666 -6
  17. package/mcp_server/tools/browser.py +164 -13
  18. package/mcp_server/tools/devices.py +56 -11
  19. package/mcp_server/tools/mixing.py +64 -15
  20. package/mcp_server/tools/scales.py +18 -6
  21. package/mcp_server/tools/tracks.py +92 -4
  22. package/package.json +2 -2
  23. package/remote_script/LivePilot/__init__.py +2 -1
  24. package/remote_script/LivePilot/_clip_helpers.py +86 -0
  25. package/remote_script/LivePilot/_drum_helpers.py +40 -0
  26. package/remote_script/LivePilot/_scale_helpers.py +87 -0
  27. package/remote_script/LivePilot/arrangement.py +44 -15
  28. package/remote_script/LivePilot/clips.py +182 -2
  29. package/remote_script/LivePilot/devices.py +82 -2
  30. package/remote_script/LivePilot/notes.py +17 -2
  31. package/remote_script/LivePilot/scales.py +31 -16
  32. package/remote_script/LivePilot/simpler_sample.py +186 -0
  33. package/server.json +3 -3
@@ -0,0 +1,361 @@
1
+ """HTTPS bridge for Splice plugin-exclusive features.
2
+
3
+ The Splice Sounds Plugin (beta) ships two capabilities that are NOT on the
4
+ local gRPC service:
5
+ - **Describe a Sound** — natural-language search ("dark ambient pad
6
+ with shimmer")
7
+ - **Variations** — generate unique re-keyed / re-tempo'd versions of
8
+ any sample
9
+
10
+ Both call `api.splice.com` over HTTPS, authenticated with the session
11
+ token we can read from the local gRPC `GetSession` RPC.
12
+
13
+ This module is *scaffolding* — it builds the auth flow, endpoint URLs,
14
+ response parsing, and retry/timeout plumbing so that capturing the real
15
+ endpoint shapes (via mitmproxy against the running plugin) is a matter
16
+ of updating the URL templates rather than rebuilding infrastructure.
17
+
18
+ ## How to go from scaffolding to working tool
19
+
20
+ 1. Run mitmproxy in transparent mode against the Splice Sounds Plugin
21
+ while it makes a Describe a Sound or Variations request.
22
+ 2. Capture the real endpoint URL, request body shape, and response body.
23
+ 3. Drop the values into `SpliceHTTPConfig` defaults or via env vars:
24
+ - `SPLICE_API_BASE_URL` (default: https://api.splice.com)
25
+ - `SPLICE_DESCRIBE_ENDPOINT` (default: /v1/describe)
26
+ - `SPLICE_VARIATION_ENDPOINT` (default: /v1/variations/{file_hash})
27
+ 4. Run `splice_describe_sound("dark pad")` — done.
28
+
29
+ Until step 4 is complete, the MCP tools return a clear, actionable error
30
+ rather than pretending to work. Zero cheats.
31
+
32
+ ## Why token-based instead of embedding the plugin
33
+
34
+ The plugin's authentication flow uses Splice's OAuth session tokens.
35
+ These rotate periodically — hardcoding them wouldn't work. Reading from
36
+ `GetSession` RPC means we always use the current session, tied to the
37
+ user's currently-logged-in Splice desktop app.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ import asyncio
43
+ import json
44
+ import logging
45
+ import os
46
+ import ssl
47
+ import urllib.error
48
+ import urllib.request
49
+ from dataclasses import dataclass, field
50
+ from typing import Any, Optional
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+
55
+ # ── Configuration ─────────────────────────────────────────────────────
56
+
57
+
58
+ @dataclass
59
+ class SpliceHTTPConfig:
60
+ """Endpoint configuration for the HTTPS bridge.
61
+
62
+ All fields have env-var overrides so a dev can swap them for testing
63
+ without code changes. Defaults are best-guesses based on Splice's
64
+ public URL conventions — they WILL need updating when we capture real
65
+ traffic. That's expected.
66
+ """
67
+
68
+ base_url: str = "https://api.splice.com"
69
+ describe_endpoint: str = "/v1/describe"
70
+ variation_endpoint: str = "/v1/variations/{file_hash}"
71
+ search_with_sound_endpoint: str = "/v1/search-with-sound"
72
+ timeout_sec: float = 30.0
73
+ max_retries: int = 2
74
+
75
+ @classmethod
76
+ def from_env(cls) -> "SpliceHTTPConfig":
77
+ """Load config from env vars, falling back to defaults."""
78
+ return cls(
79
+ base_url=os.environ.get("SPLICE_API_BASE_URL", cls.base_url),
80
+ describe_endpoint=os.environ.get(
81
+ "SPLICE_DESCRIBE_ENDPOINT", cls.describe_endpoint,
82
+ ),
83
+ variation_endpoint=os.environ.get(
84
+ "SPLICE_VARIATION_ENDPOINT", cls.variation_endpoint,
85
+ ),
86
+ search_with_sound_endpoint=os.environ.get(
87
+ "SPLICE_SEARCH_WITH_SOUND_ENDPOINT",
88
+ cls.search_with_sound_endpoint,
89
+ ),
90
+ timeout_sec=float(os.environ.get("SPLICE_HTTP_TIMEOUT", cls.timeout_sec)),
91
+ max_retries=int(os.environ.get("SPLICE_HTTP_RETRIES", cls.max_retries)),
92
+ )
93
+
94
+ @property
95
+ def is_user_configured(self) -> bool:
96
+ """True when at least one endpoint URL has been overridden by env var.
97
+
98
+ Defaults are unverified guesses; callers check this before making
99
+ requests so we don't silently hit non-existent endpoints.
100
+ """
101
+ return (
102
+ "SPLICE_API_BASE_URL" in os.environ
103
+ or "SPLICE_DESCRIBE_ENDPOINT" in os.environ
104
+ or "SPLICE_VARIATION_ENDPOINT" in os.environ
105
+ or "SPLICE_SEARCH_WITH_SOUND_ENDPOINT" in os.environ
106
+ or os.environ.get("SPLICE_ALLOW_UNVERIFIED_ENDPOINTS") == "1"
107
+ )
108
+
109
+
110
+ # ── Auth token fetch ─────────────────────────────────────────────────
111
+
112
+
113
+ async def fetch_session_token(grpc_client) -> Optional[str]:
114
+ """Fetch the current Splice session token from the local gRPC.
115
+
116
+ The `GetSession` RPC returns an `Auth` object with a `Token` field —
117
+ this is the bearer we attach to `api.splice.com` requests. The token
118
+ rotates periodically so we always fetch fresh rather than caching.
119
+ """
120
+ if not grpc_client or not getattr(grpc_client, "connected", False):
121
+ return None
122
+ pb2 = getattr(grpc_client, "_pb2", None)
123
+ if pb2 is None:
124
+ return None
125
+ try:
126
+ response = await grpc_client.stub.GetSession(
127
+ pb2.GetSessionRequest(), timeout=5.0,
128
+ )
129
+ return str(response.Auth.Token) if response.Auth else None
130
+ except Exception as exc:
131
+ logger.warning("GetSession RPC failed: %s", exc)
132
+ return None
133
+
134
+
135
+ # ── HTTP client ───────────────────────────────────────────────────────
136
+
137
+
138
+ @dataclass
139
+ class SpliceHTTPError(Exception):
140
+ """Structured error for HTTPS-bridge calls."""
141
+
142
+ code: str
143
+ message: str
144
+ endpoint: str = ""
145
+ status_code: int = 0
146
+
147
+ def __str__(self) -> str:
148
+ return f"[{self.code}] {self.message} ({self.endpoint})"
149
+
150
+ def to_dict(self) -> dict:
151
+ return {
152
+ "ok": False,
153
+ "error": self.message,
154
+ "code": self.code,
155
+ "endpoint": self.endpoint,
156
+ "status_code": self.status_code,
157
+ }
158
+
159
+
160
+ class SpliceHTTPBridge:
161
+ """Low-level HTTPS client for Splice cloud APIs.
162
+
163
+ Attaches the bearer token, retries on 5xx, applies a total timeout.
164
+ Thread-safe — each request builds its own opener. Synchronous network
165
+ calls run in an executor from the async wrappers.
166
+ """
167
+
168
+ def __init__(
169
+ self,
170
+ config: Optional[SpliceHTTPConfig] = None,
171
+ grpc_client=None,
172
+ ):
173
+ self.config = config or SpliceHTTPConfig.from_env()
174
+ self.grpc_client = grpc_client
175
+
176
+ async def _request(
177
+ self,
178
+ method: str,
179
+ path: str,
180
+ body: Optional[dict] = None,
181
+ query: Optional[dict] = None,
182
+ ) -> Any:
183
+ token = await fetch_session_token(self.grpc_client)
184
+ if token is None:
185
+ raise SpliceHTTPError(
186
+ code="NO_AUTH",
187
+ message=(
188
+ "Could not fetch Splice session token via GetSession RPC. "
189
+ "Is the Splice desktop app running and logged in?"
190
+ ),
191
+ endpoint=path,
192
+ )
193
+
194
+ url = self.config.base_url.rstrip("/") + path
195
+ if query:
196
+ import urllib.parse
197
+ qs = urllib.parse.urlencode(query)
198
+ url = f"{url}?{qs}"
199
+
200
+ data_bytes = None
201
+ headers = {
202
+ "Authorization": f"Bearer {token}",
203
+ "Accept": "application/json",
204
+ "User-Agent": "LivePilot/1.15 (+splice-http-bridge)",
205
+ }
206
+ if body is not None:
207
+ data_bytes = json.dumps(body).encode("utf-8")
208
+ headers["Content-Type"] = "application/json"
209
+
210
+ loop = asyncio.get_running_loop()
211
+ last_err = None
212
+ for attempt in range(1 + max(0, self.config.max_retries)):
213
+ try:
214
+ return await loop.run_in_executor(
215
+ None,
216
+ self._perform_sync_request,
217
+ url, method, data_bytes, headers,
218
+ )
219
+ except SpliceHTTPError as exc:
220
+ last_err = exc
221
+ # Retry only on 5xx / network. 4xx is terminal.
222
+ if exc.status_code and exc.status_code < 500:
223
+ raise
224
+ await asyncio.sleep(min(2 ** attempt, 5))
225
+ assert last_err is not None
226
+ raise last_err
227
+
228
+ def _perform_sync_request(self, url, method, data_bytes, headers):
229
+ try:
230
+ req = urllib.request.Request(
231
+ url, data=data_bytes, headers=headers, method=method,
232
+ )
233
+ context = ssl.create_default_context()
234
+ with urllib.request.urlopen(
235
+ req, timeout=self.config.timeout_sec, context=context,
236
+ ) as resp:
237
+ raw = resp.read()
238
+ content_type = resp.headers.get("Content-Type", "")
239
+ if "application/json" in content_type:
240
+ return json.loads(raw.decode("utf-8"))
241
+ return {"raw": raw.decode("utf-8", errors="replace")}
242
+ except urllib.error.HTTPError as exc:
243
+ raise SpliceHTTPError(
244
+ code="HTTP_ERROR",
245
+ message=f"HTTP {exc.code}: {exc.reason}",
246
+ endpoint=url,
247
+ status_code=exc.code,
248
+ )
249
+ except urllib.error.URLError as exc:
250
+ raise SpliceHTTPError(
251
+ code="NETWORK_ERROR",
252
+ message=f"Network error: {exc.reason}",
253
+ endpoint=url,
254
+ status_code=0,
255
+ )
256
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
257
+ raise SpliceHTTPError(
258
+ code="DECODE_ERROR",
259
+ message=f"Response decode failed: {exc}",
260
+ endpoint=url,
261
+ )
262
+
263
+ # ── Tool-facing helpers ──────────────────────────────────────────
264
+
265
+ async def describe_sound(
266
+ self,
267
+ description: str,
268
+ bpm: Optional[int] = None,
269
+ key: Optional[str] = None,
270
+ limit: int = 20,
271
+ ) -> dict:
272
+ """Natural-language sample search.
273
+
274
+ Returns a dict with keys: `samples` (list of sample metadata),
275
+ `total_hits`, plus whatever Splice echoes back. Shape is best-effort
276
+ until we capture real traffic — see module docstring.
277
+ """
278
+ if not self.config.is_user_configured:
279
+ raise SpliceHTTPError(
280
+ code="ENDPOINT_NOT_CONFIGURED",
281
+ message=(
282
+ "Describe a Sound endpoint is unverified. Set "
283
+ "SPLICE_DESCRIBE_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
284
+ "ENDPOINTS=1) once you've captured the real URL via "
285
+ "mitmproxy against the Sounds Plugin."
286
+ ),
287
+ endpoint=self.config.describe_endpoint,
288
+ )
289
+ body = {
290
+ "description": description,
291
+ "limit": int(limit),
292
+ }
293
+ if bpm is not None:
294
+ body["bpm"] = int(bpm)
295
+ if key:
296
+ body["key"] = key
297
+ return await self._request("POST", self.config.describe_endpoint, body=body)
298
+
299
+ async def generate_variation(
300
+ self,
301
+ file_hash: str,
302
+ target_key: Optional[str] = None,
303
+ target_bpm: Optional[int] = None,
304
+ count: int = 1,
305
+ ) -> dict:
306
+ """Generate AI variations of a sample.
307
+
308
+ Returns a dict with keys: `variations` (list), `credits_spent`.
309
+ Shape is best-effort until captured — see module docstring.
310
+ """
311
+ if not self.config.is_user_configured:
312
+ raise SpliceHTTPError(
313
+ code="ENDPOINT_NOT_CONFIGURED",
314
+ message=(
315
+ "Variations endpoint is unverified. Set "
316
+ "SPLICE_VARIATION_ENDPOINT (or SPLICE_ALLOW_UNVERIFIED_"
317
+ "ENDPOINTS=1) once you've captured the real URL via "
318
+ "mitmproxy against the Sounds Plugin."
319
+ ),
320
+ endpoint=self.config.variation_endpoint,
321
+ )
322
+ path = self.config.variation_endpoint.format(file_hash=file_hash)
323
+ body: dict = {"count": max(1, int(count))}
324
+ if target_key:
325
+ body["target_key"] = target_key
326
+ if target_bpm is not None:
327
+ body["target_bpm"] = int(target_bpm)
328
+ return await self._request("POST", path, body=body)
329
+
330
+ async def search_with_sound(
331
+ self,
332
+ audio_path: str,
333
+ limit: int = 20,
334
+ ) -> dict:
335
+ """Sample-reference search — find catalog samples similar to a file.
336
+
337
+ Encodes the file as a multipart POST. Wiring waits on a real
338
+ endpoint capture; the upload shape is the most uncertain part
339
+ of the bridge.
340
+ """
341
+ if not self.config.is_user_configured:
342
+ raise SpliceHTTPError(
343
+ code="ENDPOINT_NOT_CONFIGURED",
344
+ message=(
345
+ "Search with Sound endpoint is unverified. Set "
346
+ "SPLICE_SEARCH_WITH_SOUND_ENDPOINT (or SPLICE_ALLOW_"
347
+ "UNVERIFIED_ENDPOINTS=1) once you've captured the real "
348
+ "URL via mitmproxy against the Sounds Plugin."
349
+ ),
350
+ endpoint=self.config.search_with_sound_endpoint,
351
+ )
352
+ # Multipart upload — reserved for the real-capture wiring.
353
+ raise SpliceHTTPError(
354
+ code="NOT_YET_IMPLEMENTED",
355
+ message=(
356
+ "search_with_sound multipart upload wiring is pending real-"
357
+ "endpoint capture. File a follow-up when the Describe a "
358
+ "Sound endpoint has been mapped — similar shape is likely."
359
+ ),
360
+ endpoint=self.config.search_with_sound_endpoint,
361
+ )
@@ -1,11 +1,137 @@
1
- """Splice client data models — Python representations of Splice gRPC messages."""
1
+ """Splice client data models — Python representations of Splice gRPC messages.
2
+
3
+ Models here mirror the proto messages under `protos/app_pb2.py`.
4
+ See `project_splice_subscription_model.md` for the two-pocket model
5
+ (daily samples vs Splice.com credits) that PlanKind classifies.
6
+ """
2
7
 
3
8
  from __future__ import annotations
4
9
 
5
10
  from dataclasses import dataclass, field
11
+ from enum import Enum
6
12
  from typing import Optional
7
13
 
8
14
 
15
+ # ── Plan classification ──────────────────────────────────────────────────
16
+
17
+
18
+ class PlanKind(str, Enum):
19
+ """Classification of Splice subscription plans.
20
+
21
+ Splice returns `User.SoundsStatus` as a generic string (often just
22
+ "subscribed"), so we classify tier via the `Features` map and numeric
23
+ `SoundsPlan` id. See `project_splice_subscription_model.md`.
24
+
25
+ - ABLETON_LIVE: $12.99/mo, 100 samples/day unmetered via Ableton drag-drop
26
+ + 100 intro credits for Splice.com content. Sample downloads DO NOT
27
+ cost credits on this plan — they deplete a daily counter.
28
+ - SOUNDS_PLUS: legacy per-credit tiers (100/300/600/1000) + Creator+.
29
+ Samples DO cost credits.
30
+ - CREATOR: $12.99/mo legacy creator plan, 100 credits/mo.
31
+ - FREE: anonymous or unconverted trial.
32
+ - UNKNOWN: plan metadata absent — treat like SOUNDS_PLUS (safest).
33
+ """
34
+
35
+ ABLETON_LIVE = "ableton_live"
36
+ SOUNDS_PLUS = "sounds_plus"
37
+ CREATOR = "creator"
38
+ CREATOR_PLUS = "creator_plus"
39
+ FREE = "free"
40
+ UNKNOWN = "unknown"
41
+
42
+ @property
43
+ def is_subscribed(self) -> bool:
44
+ return self != PlanKind.FREE and self != PlanKind.UNKNOWN
45
+
46
+ @property
47
+ def has_daily_sample_quota(self) -> bool:
48
+ """True iff sample downloads deplete a daily counter, not credits."""
49
+ return self == PlanKind.ABLETON_LIVE
50
+
51
+
52
+ # Feature-flag keys we look for in `User.Features`. Splice sets these in
53
+ # the ValidateLogin response. Names are best-effort — Splice may rename
54
+ # them; the classifier tolerates missing keys.
55
+ _FEATURE_ABLETON_UNMETERED = "ableton_unmetered"
56
+ _FEATURE_ABLETON_LIVE_PLAN = "ableton_live_plan"
57
+ _FEATURE_UNMETERED = "unmetered_downloads"
58
+ _FEATURE_CREATOR_PLUS = "creator_plus"
59
+
60
+ # Numeric plan IDs we've observed. Splice uses `User.SoundsPlan` as a
61
+ # proprietary enum. These are inferred from the API responses and the
62
+ # public plan catalog.
63
+ _PLAN_ID_ABLETON_LIVE = {12, 13} # possible IDs for the Ableton plan
64
+ _PLAN_ID_CREATOR_PLUS = {11}
65
+ _PLAN_ID_CREATOR = {1, 2, 3}
66
+ _PLAN_ID_FREE = {0}
67
+
68
+
69
+ def classify_plan(
70
+ sounds_status: str,
71
+ sounds_plan: int,
72
+ features: Optional[dict[str, bool]] = None,
73
+ ) -> PlanKind:
74
+ """Classify the user's Splice plan from the ValidateLogin response.
75
+
76
+ Priority order (most authoritative first):
77
+ 1. Feature flags — if `ableton_unmetered` etc. is set, trust it.
78
+ 2. Non-zero numeric plan IDs we recognize.
79
+ 3. Free-form status string heuristics — catches "subscribed",
80
+ "ableton live plan", "creator plus", etc.
81
+ 4. Numeric 0 → FREE only when the status string doesn't
82
+ contradict. (plan_id=0 alone with status="subscribed" is NOT
83
+ free — it's just a plan we don't have a numeric ID for yet.)
84
+ 5. Fallback: UNKNOWN so callers keep the safe credit-floor default.
85
+ """
86
+ features = features or {}
87
+
88
+ # Step 1: feature flags are authoritative
89
+ if features.get(_FEATURE_ABLETON_UNMETERED) or features.get(_FEATURE_ABLETON_LIVE_PLAN):
90
+ return PlanKind.ABLETON_LIVE
91
+ if features.get(_FEATURE_UNMETERED):
92
+ return PlanKind.ABLETON_LIVE
93
+ if features.get(_FEATURE_CREATOR_PLUS):
94
+ return PlanKind.CREATOR_PLUS
95
+
96
+ # Step 2: recognized non-zero plan IDs
97
+ if sounds_plan in _PLAN_ID_ABLETON_LIVE:
98
+ return PlanKind.ABLETON_LIVE
99
+ if sounds_plan in _PLAN_ID_CREATOR_PLUS:
100
+ return PlanKind.CREATOR_PLUS
101
+ if sounds_plan in _PLAN_ID_CREATOR:
102
+ return PlanKind.CREATOR
103
+
104
+ # Step 3: string heuristics — BEFORE the plan_id=0 FREE check, because
105
+ # "subscribed" + plan_id=0 means "subscribed plan we don't recognize
106
+ # numerically", NOT free.
107
+ status_lower = (sounds_status or "").lower().strip()
108
+ if "ableton" in status_lower:
109
+ return PlanKind.ABLETON_LIVE
110
+ if "creator" in status_lower and "plus" in status_lower:
111
+ return PlanKind.CREATOR_PLUS
112
+ if "creator" in status_lower:
113
+ return PlanKind.CREATOR
114
+ if status_lower in ("subscribed", "paid", "active", "sounds_plus", "sounds+"):
115
+ # Generic "subscribed" is ambiguous — SOUNDS_PLUS is the safe
116
+ # default because it keeps the credit floor on. The MCP tool
117
+ # documents this.
118
+ return PlanKind.SOUNDS_PLUS
119
+ if status_lower in ("free", "trial", "unconverted"):
120
+ return PlanKind.FREE
121
+
122
+ # Step 4: numeric FREE path — only reached when status was silent
123
+ if sounds_plan in _PLAN_ID_FREE:
124
+ return PlanKind.FREE
125
+
126
+ # Step 5: fallback
127
+ if not status_lower:
128
+ return PlanKind.FREE
129
+ return PlanKind.UNKNOWN
130
+
131
+
132
+ # ── Sample & search ──────────────────────────────────────────────────────
133
+
134
+
9
135
  @dataclass
10
136
  class SpliceSample:
11
137
  """A sample from the Splice catalog or local library."""
@@ -24,6 +150,7 @@ class SpliceSample:
24
150
  pack_uuid: str = ""
25
151
  popularity: int = 0
26
152
  is_premium: bool = False
153
+ price: int = 0 # 0 ⇒ free regardless of plan
27
154
  preview_url: str = ""
28
155
  waveform_url: str = ""
29
156
  is_downloaded: bool = False
@@ -42,6 +169,15 @@ class SpliceSample:
42
169
  def duration_seconds(self) -> float:
43
170
  return self.duration_ms / 1000.0 if self.duration_ms else 0.0
44
171
 
172
+ @property
173
+ def is_free(self) -> bool:
174
+ """True iff this sample costs no credits under any plan.
175
+
176
+ Splice marks samples as free via `IsPremium == False` or `Price == 0`.
177
+ This is orthogonal to plan: even a free-tier user can license these.
178
+ """
179
+ return (not self.is_premium) or self.price == 0
180
+
45
181
  def to_dict(self) -> dict:
46
182
  return {
47
183
  "file_hash": self.file_hash,
@@ -60,6 +196,9 @@ class SpliceSample:
60
196
  "popularity": self.popularity,
61
197
  "is_downloaded": self.is_downloaded,
62
198
  "is_premium": self.is_premium,
199
+ "price": self.price,
200
+ "is_free": self.is_free,
201
+ "preview_url": self.preview_url,
63
202
  }
64
203
 
65
204
 
@@ -80,17 +219,142 @@ class SpliceSearchResult:
80
219
  }
81
220
 
82
221
 
222
+ # ── Credits & plan ───────────────────────────────────────────────────────
223
+
224
+
83
225
  @dataclass
84
226
  class SpliceCredits:
85
- """User credit status."""
227
+ """User credit status plus plan classification.
228
+
229
+ `plan` is the raw Splice `SoundsStatus` string (e.g. "subscribed").
230
+ `plan_kind` is our classification — use this for gating decisions.
231
+ `features` carries the full `Features` map so callers can check
232
+ granular flags not yet modelled by PlanKind.
233
+ """
86
234
 
87
235
  credits: int = 0
88
236
  username: str = ""
89
237
  plan: str = ""
238
+ sounds_plan_id: int = 0
239
+ features: dict[str, bool] = field(default_factory=dict)
240
+ plan_kind: PlanKind = PlanKind.UNKNOWN
241
+ user_uuid: str = ""
90
242
 
91
243
  def to_dict(self) -> dict:
92
244
  return {
93
245
  "credits": self.credits,
94
246
  "username": self.username,
95
247
  "plan": self.plan,
248
+ "sounds_plan_id": self.sounds_plan_id,
249
+ "plan_kind": self.plan_kind.value,
250
+ "features": dict(self.features),
251
+ "user_uuid": self.user_uuid,
252
+ }
253
+
254
+
255
+ # ── Collections (Splice-side personal organization) ──────────────────────
256
+
257
+
258
+ @dataclass
259
+ class SpliceCollection:
260
+ """A user-curated Collection (Likes, custom folders, Daily Picks bookmark)."""
261
+
262
+ uuid: str = ""
263
+ name: str = ""
264
+ description: str = ""
265
+ access: str = "" # "public", "private"
266
+ permalink: str = ""
267
+ cover_url: str = ""
268
+ sample_count: int = 0
269
+ preset_count: int = 0
270
+ pack_count: int = 0
271
+ subscription_count: int = 0
272
+ created_by_current_user: bool = False
273
+ creator_username: str = ""
274
+ created_at: str = ""
275
+ updated_at: str = ""
276
+
277
+ def to_dict(self) -> dict:
278
+ return {
279
+ "uuid": self.uuid,
280
+ "name": self.name,
281
+ "description": self.description,
282
+ "access": self.access,
283
+ "permalink": self.permalink,
284
+ "sample_count": self.sample_count,
285
+ "preset_count": self.preset_count,
286
+ "pack_count": self.pack_count,
287
+ "cover_url": self.cover_url,
288
+ "owned_by_me": self.created_by_current_user,
289
+ "creator": self.creator_username,
290
+ "created_at": self.created_at,
291
+ "updated_at": self.updated_at,
292
+ }
293
+
294
+
295
+ # ── Packs ────────────────────────────────────────────────────────────────
296
+
297
+
298
+ @dataclass
299
+ class SplicePack:
300
+ """A sample pack (Splice `SamplePack` message)."""
301
+
302
+ uuid: str = ""
303
+ name: str = ""
304
+ cover_url: str = ""
305
+ genre: str = ""
306
+ permalink: str = ""
307
+ provider_name: str = ""
308
+
309
+ def to_dict(self) -> dict:
310
+ return {
311
+ "uuid": self.uuid,
312
+ "name": self.name,
313
+ "genre": self.genre,
314
+ "permalink": self.permalink,
315
+ "provider": self.provider_name,
316
+ "cover_url": self.cover_url,
317
+ }
318
+
319
+
320
+ # ── Presets ──────────────────────────────────────────────────────────────
321
+
322
+
323
+ @dataclass
324
+ class SplicePreset:
325
+ """A Splice Instrument or VST/AU preset from the catalog."""
326
+
327
+ uuid: str = ""
328
+ file_hash: str = ""
329
+ filename: str = ""
330
+ local_path: str = ""
331
+ tags: list[str] = field(default_factory=list)
332
+ price: int = 0
333
+ is_default: bool = False
334
+ plugin_name: str = ""
335
+ plugin_version: str = ""
336
+ provider_name: str = ""
337
+ pack_uuid: str = ""
338
+ preview_url: str = ""
339
+ purchased_at: int = 0
340
+
341
+ @property
342
+ def is_downloaded(self) -> bool:
343
+ return bool(self.local_path)
344
+
345
+ def to_dict(self) -> dict:
346
+ return {
347
+ "uuid": self.uuid,
348
+ "file_hash": self.file_hash,
349
+ "filename": self.filename,
350
+ "local_path": self.local_path,
351
+ "tags": self.tags,
352
+ "price": self.price,
353
+ "plugin_name": self.plugin_name,
354
+ "plugin_version": self.plugin_version,
355
+ "provider": self.provider_name,
356
+ "pack_uuid": self.pack_uuid,
357
+ "is_default": self.is_default,
358
+ "is_downloaded": self.is_downloaded,
359
+ "preview_url": self.preview_url,
96
360
  }