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
@@ -3,8 +3,25 @@
3
3
  Splice runs a gRPC server (Go binary) on localhost with TLS.
4
4
  Port is dynamic (read from port.conf). Certs are self-signed.
5
5
 
6
- This client provides: search, download, sample info, credit check.
6
+ This client wraps the full `proto.App` service surface:
7
+ - Search / download / sample-info
8
+ - Credits + plan classification (see models.PlanKind)
9
+ - Collections (list, samples, add/remove items, create, update, delete)
10
+ - Presets (purchased list, download, info, purchase)
11
+ - Packs (info)
12
+ - Imported samples (user's own directories indexed by Splice)
13
+ - Convert to WAV (for non-WAV sources)
14
+ - Preview URL fetching (zero-credit audition)
15
+
7
16
  All methods are async. Graceful degradation when Splice is not running.
17
+
18
+ Plan-aware gating (see `project_splice_subscription_model.md`):
19
+ - On the Ableton Live plan, sample downloads deplete a DAILY counter
20
+ (100/day) rather than credits. `can_download_sample()` checks both
21
+ the daily quota AND the credit floor, choosing the right budget for
22
+ the user's actual plan.
23
+ - Free samples (`Sample.IsPremium == False` or `Price == 0`) bypass
24
+ gating entirely — they're free under any plan.
8
25
  """
9
26
 
10
27
  from __future__ import annotations
@@ -13,9 +30,20 @@ import asyncio
13
30
  import glob
14
31
  import logging
15
32
  import os
33
+ from dataclasses import dataclass
16
34
  from typing import Optional
17
35
 
18
- from .models import SpliceCredits, SpliceSample, SpliceSearchResult
36
+ from .models import (
37
+ PlanKind,
38
+ SpliceCollection,
39
+ SpliceCredits,
40
+ SplicePack,
41
+ SplicePreset,
42
+ SpliceSample,
43
+ SpliceSearchResult,
44
+ classify_plan,
45
+ )
46
+ from .quota import DailyQuotaTracker, get_tracker
19
47
 
20
48
  logger = logging.getLogger(__name__)
21
49
 
@@ -24,7 +52,8 @@ _SPLICE_APP_SUPPORT = os.path.expanduser(
24
52
  "~/Library/Application Support/com.splice.Splice"
25
53
  )
26
54
 
27
- # Credit safety floor — never drain below this
55
+ # Credit safety floor — never drain below this on credit-metered plans.
56
+ # Does NOT apply to the Ableton Live plan, which uses a daily counter.
28
57
  CREDIT_HARD_FLOOR = 5
29
58
 
30
59
  # Per-call gRPC timeouts. The previous implementation passed no timeout, so
@@ -36,6 +65,40 @@ INFO_TIMEOUT = 5.0
36
65
  CREDITS_TIMEOUT = 5.0
37
66
  SYNC_TIMEOUT = 30.0
38
67
  DOWNLOAD_TRIGGER_TIMEOUT = 5.0
68
+ COLLECTION_TIMEOUT = 10.0
69
+ PRESET_TIMEOUT = 10.0
70
+ CONVERT_TIMEOUT = 30.0
71
+
72
+
73
+ @dataclass
74
+ class DownloadDecision:
75
+ """Result of pre-download gating logic.
76
+
77
+ `allowed` — whether to proceed with the download.
78
+ `reason` — human-readable explanation.
79
+ `plan_kind` — plan classification used for the decision.
80
+ `credits_remaining` / `quota_used` / `quota_remaining` — state snapshot.
81
+ `gating_mode` — "free_sample", "daily_quota", "credit_floor", or "blocked".
82
+ """
83
+
84
+ allowed: bool
85
+ reason: str
86
+ plan_kind: PlanKind
87
+ gating_mode: str
88
+ credits_remaining: int = 0
89
+ quota_used: int = 0
90
+ quota_remaining: int = 0
91
+
92
+ def to_dict(self) -> dict:
93
+ return {
94
+ "allowed": self.allowed,
95
+ "reason": self.reason,
96
+ "plan_kind": self.plan_kind.value,
97
+ "gating_mode": self.gating_mode,
98
+ "credits_remaining": self.credits_remaining,
99
+ "quota_used": self.quota_used,
100
+ "quota_remaining": self.quota_remaining,
101
+ }
39
102
 
40
103
 
41
104
  def _try_import_grpc():
@@ -59,13 +122,16 @@ def _try_import_protos():
59
122
  class SpliceGRPCClient:
60
123
  """Async gRPC client for Splice desktop's App service."""
61
124
 
62
- def __init__(self):
125
+ def __init__(self, quota_tracker: Optional[DailyQuotaTracker] = None):
63
126
  self.channel = None
64
127
  self.stub = None
65
128
  self.connected = False
66
129
  self._port: Optional[int] = None
67
130
  self._grpc = _try_import_grpc()
68
131
  self._pb2, self._pb2_grpc = _try_import_protos()
132
+ self._quota = quota_tracker or get_tracker()
133
+ # Cached plan state — refreshed on every explicit get_credits() call.
134
+ self._cached_credits: Optional[SpliceCredits] = None
69
135
 
70
136
  @property
71
137
  def available(self) -> bool:
@@ -132,14 +198,20 @@ class SpliceGRPCClient:
132
198
  per_page: int = 20,
133
199
  page: int = 1,
134
200
  purchased_only: bool = False,
201
+ collection_uuid: str = "",
202
+ file_hash: str = "",
135
203
  ) -> SpliceSearchResult:
136
- """Search Splice catalog. Returns ranked results with full metadata."""
204
+ """Search Splice catalog. Returns ranked results with full metadata.
205
+
206
+ `collection_uuid` scopes search to a single user collection
207
+ (e.g. "Likes", "bass") — pure taste signal when present.
208
+ `file_hash` is a direct lookup for a single sample.
209
+ """
137
210
  if not self.connected:
138
211
  return SpliceSearchResult()
139
212
 
140
213
  pb2 = self._pb2
141
214
  try:
142
- # Build search request
143
215
  purchased = 0 # All
144
216
  if purchased_only:
145
217
  purchased = 1 # OnlyPurchased
@@ -157,6 +229,8 @@ class SpliceGRPCClient:
157
229
  PerPage=per_page,
158
230
  Page=page,
159
231
  Purchased=purchased,
232
+ CollectionUUID=collection_uuid,
233
+ FileHash=file_hash,
160
234
  )
161
235
  response = await self.stub.SearchSamples(request, timeout=SEARCH_TIMEOUT)
162
236
  return self._parse_search_response(response)
@@ -168,54 +242,159 @@ class SpliceGRPCClient:
168
242
  """Convert protobuf SearchSampleResponse to our models."""
169
243
  samples = []
170
244
  for s in response.Samples:
171
- samples.append(SpliceSample(
172
- file_hash=s.FileHash,
173
- filename=s.Filename,
174
- local_path=s.LocalPath,
175
- audio_key=s.AudioKey,
176
- chord_type=s.ChordType,
177
- bpm=s.BPM,
178
- duration_ms=s.Duration,
179
- genre=s.Genre,
180
- sample_type=s.SampleType,
181
- tags=list(s.Tags),
182
- provider_name=s.ProviderName,
183
- pack_uuid=s.PackUUID,
184
- popularity=s.Popularity,
185
- is_premium=s.IsPremium,
186
- preview_url=s.PreviewURL,
187
- waveform_url=s.WaveformURL,
188
- is_downloaded=bool(s.LocalPath),
189
- ))
245
+ samples.append(self._parse_sample(s))
190
246
  return SpliceSearchResult(
191
247
  total_hits=response.TotalHits,
192
248
  samples=samples,
193
249
  matching_tags=dict(response.MatchingTags),
194
250
  )
195
251
 
252
+ def _parse_sample(self, s) -> SpliceSample:
253
+ """Convert a single protobuf Sample to our model."""
254
+ return SpliceSample(
255
+ file_hash=s.FileHash,
256
+ filename=s.Filename,
257
+ local_path=s.LocalPath,
258
+ audio_key=s.AudioKey,
259
+ chord_type=s.ChordType,
260
+ bpm=s.BPM,
261
+ duration_ms=s.Duration,
262
+ genre=s.Genre,
263
+ sample_type=s.SampleType,
264
+ tags=list(s.Tags),
265
+ provider_name=s.ProviderName,
266
+ pack_uuid=s.PackUUID,
267
+ popularity=s.Popularity,
268
+ is_premium=s.IsPremium,
269
+ price=s.Price if hasattr(s, "Price") else 0,
270
+ preview_url=s.PreviewURL,
271
+ waveform_url=s.WaveformURL,
272
+ is_downloaded=bool(s.LocalPath),
273
+ )
274
+
196
275
  # ── Download ────────────────────────────────────────────────────
197
276
 
277
+ async def decide_download(
278
+ self,
279
+ file_hash: str,
280
+ sample: Optional[SpliceSample] = None,
281
+ ) -> DownloadDecision:
282
+ """Run plan-aware gating logic for a prospective download.
283
+
284
+ The caller passes the `SpliceSample` when known (from a prior
285
+ search); we use it to detect `is_free` and skip all gating.
286
+ When not known we do NOT fetch the sample — that would waste
287
+ a SampleInfo round-trip. Unknown samples default to paid.
288
+ """
289
+ if not self.connected:
290
+ return DownloadDecision(
291
+ allowed=False,
292
+ reason="Splice desktop app not reachable",
293
+ plan_kind=PlanKind.UNKNOWN,
294
+ gating_mode="blocked",
295
+ )
296
+
297
+ # Fast path: free samples bypass every gate.
298
+ if sample is not None and sample.is_free:
299
+ return DownloadDecision(
300
+ allowed=True,
301
+ reason=(
302
+ "Sample is free (Price=0 or !IsPremium) — no credit or "
303
+ "quota cost under any plan."
304
+ ),
305
+ plan_kind=(
306
+ self._cached_credits.plan_kind
307
+ if self._cached_credits
308
+ else PlanKind.UNKNOWN
309
+ ),
310
+ gating_mode="free_sample",
311
+ )
312
+
313
+ # Refresh plan + credit state for this decision.
314
+ credits = await self.get_credits()
315
+ plan = credits.plan_kind
316
+
317
+ if plan == PlanKind.ABLETON_LIVE:
318
+ quota = self._quota.summary()
319
+ if quota["at_limit"]:
320
+ return DownloadDecision(
321
+ allowed=False,
322
+ reason=(
323
+ f"Daily quota hit ({quota['used_today']}/"
324
+ f"{quota['daily_limit']}). Resets at UTC midnight."
325
+ ),
326
+ plan_kind=plan,
327
+ gating_mode="daily_quota",
328
+ credits_remaining=credits.credits,
329
+ quota_used=quota["used_today"],
330
+ quota_remaining=quota["remaining_today"],
331
+ )
332
+ return DownloadDecision(
333
+ allowed=True,
334
+ reason=(
335
+ f"Ableton Live plan: {quota['remaining_today']} of "
336
+ f"{quota['daily_limit']} daily samples remain. Download "
337
+ "will NOT deplete your 80 Splice.com credits."
338
+ ),
339
+ plan_kind=plan,
340
+ gating_mode="daily_quota",
341
+ credits_remaining=credits.credits,
342
+ quota_used=quota["used_today"],
343
+ quota_remaining=quota["remaining_today"],
344
+ )
345
+
346
+ # Credit-metered plans (SOUNDS_PLUS, CREATOR, CREATOR_PLUS, UNKNOWN).
347
+ # Keep the hard floor to avoid draining the monthly pool.
348
+ can, remaining = await self.can_afford(1, budget=1)
349
+ if not can:
350
+ return DownloadDecision(
351
+ allowed=False,
352
+ reason=(
353
+ f"Credit safety floor hit (remaining={remaining}, "
354
+ f"floor={CREDIT_HARD_FLOOR}). Download would drain "
355
+ "your monthly allotment past the safe reserve."
356
+ ),
357
+ plan_kind=plan,
358
+ gating_mode="credit_floor",
359
+ credits_remaining=remaining,
360
+ )
361
+ return DownloadDecision(
362
+ allowed=True,
363
+ reason=(
364
+ f"Credit-metered plan ({plan.value}): {remaining} credits "
365
+ "available, safely above floor."
366
+ ),
367
+ plan_kind=plan,
368
+ gating_mode="credit_floor",
369
+ credits_remaining=remaining,
370
+ )
371
+
198
372
  async def download_sample(
199
- self, file_hash: str, timeout: float = 30.0,
373
+ self,
374
+ file_hash: str,
375
+ timeout: float = 30.0,
376
+ sample: Optional[SpliceSample] = None,
200
377
  ) -> Optional[str]:
201
378
  """Download a sample by file_hash. Returns local path when complete.
202
379
 
203
- Costs 1 credit. Enforces CREDIT_HARD_FLOOR defensively — refuses the
204
- download (returns None) if completing it would leave the user at or
205
- below the floor, regardless of what the caller requested. Callers
206
- should still gate on `can_afford` upstream for UX, but this guard
207
- closes the hole if a future caller forgets.
380
+ Plan-aware gating:
381
+ - Free samples: always allowed, no counter update.
382
+ - Ableton Live plan: increments daily quota, leaves credits alone.
383
+ - Credit-metered plans: enforces CREDIT_HARD_FLOOR.
384
+
385
+ Callers should prefer `decide_download()` first for a structured
386
+ response that surfaces plan/quota state. This method is the
387
+ imperative "go download it" path; the decision is repeated here
388
+ defensively because a future caller might forget to gate.
208
389
  """
209
390
  if not self.connected:
210
391
  return None
211
392
 
212
- # Defensive floor guard — do not rely on callers alone.
213
- can, remaining = await self.can_afford(1, budget=1)
214
- if not can:
393
+ decision = await self.decide_download(file_hash, sample=sample)
394
+ if not decision.allowed:
215
395
  logger.warning(
216
- "Splice download blocked by credit floor guard "
217
- "(remaining=%s, floor=%s, file_hash=%s)",
218
- remaining, CREDIT_HARD_FLOOR, file_hash,
396
+ "Splice download refused: %s (plan=%s, mode=%s)",
397
+ decision.reason, decision.plan_kind.value, decision.gating_mode,
219
398
  )
220
399
  return None
221
400
 
@@ -227,19 +406,33 @@ class SpliceGRPCClient:
227
406
  timeout=DOWNLOAD_TRIGGER_TIMEOUT,
228
407
  )
229
408
  # Wait for file to appear on disk
230
- return await self._wait_for_download(file_hash, timeout)
409
+ local_path = await self._wait_for_download(file_hash, timeout)
231
410
  except Exception as exc:
232
411
  logger.warning(f"Splice download failed for {file_hash}: {exc}")
233
412
  return None
234
413
 
414
+ if local_path is None:
415
+ return None
416
+
417
+ # Record against the daily quota IF this was a quota-metered download.
418
+ # Free samples don't count; credit-metered samples are tracked by
419
+ # Splice server-side (our credit count will reflect on next fetch).
420
+ if decision.gating_mode == "daily_quota":
421
+ try:
422
+ self._quota.record_download(
423
+ file_hash=file_hash,
424
+ filename=os.path.basename(local_path),
425
+ )
426
+ except Exception as exc:
427
+ logger.debug("quota record_download failed: %s", exc)
428
+
429
+ return local_path
430
+
235
431
  async def _wait_for_download(
236
432
  self, file_hash: str, timeout: float,
237
433
  ) -> Optional[str]:
238
434
  """Poll SampleInfo until LocalPath is populated."""
239
435
  pb2 = self._pb2
240
- # asyncio.get_event_loop() is deprecated when called inside an
241
- # already-running coroutine on Python 3.10+. Use get_running_loop()
242
- # which is the documented replacement.
243
436
  loop = asyncio.get_running_loop()
244
437
  deadline = loop.time() + timeout
245
438
  while loop.time() < deadline:
@@ -269,30 +462,15 @@ class SpliceGRPCClient:
269
462
  pb2.SampleInfoRequest(FileHash=file_hash),
270
463
  timeout=INFO_TIMEOUT,
271
464
  )
272
- s = response.Sample
273
- return SpliceSample(
274
- file_hash=s.FileHash,
275
- filename=s.Filename,
276
- local_path=s.LocalPath,
277
- audio_key=s.AudioKey,
278
- chord_type=s.ChordType,
279
- bpm=s.BPM,
280
- duration_ms=s.Duration,
281
- genre=s.Genre,
282
- sample_type=s.SampleType,
283
- tags=list(s.Tags),
284
- provider_name=s.ProviderName,
285
- pack_uuid=s.PackUUID,
286
- is_downloaded=bool(s.LocalPath),
287
- )
465
+ return self._parse_sample(response.Sample)
288
466
  except Exception as exc:
289
467
  logger.warning(f"SampleInfo failed: {exc}")
290
468
  return None
291
469
 
292
- # ── Credits ─────────────────────────────────────────────────────
470
+ # ── Credits + Plan ──────────────────────────────────────────────
293
471
 
294
472
  async def get_credits(self) -> SpliceCredits:
295
- """Get current credit balance and user info."""
473
+ """Get current credit balance, plan, and feature-flag map."""
296
474
  if not self.connected:
297
475
  return SpliceCredits()
298
476
 
@@ -302,19 +480,39 @@ class SpliceGRPCClient:
302
480
  pb2.ValidateLoginRequest(),
303
481
  timeout=CREDITS_TIMEOUT,
304
482
  )
305
- return SpliceCredits(
306
- credits=response.User.Credits,
307
- username=response.User.Username,
308
- plan=response.User.SoundsStatus,
483
+ user = response.User
484
+ features = dict(user.Features) if hasattr(user, "Features") else {}
485
+ sounds_plan = (
486
+ int(user.SoundsPlan) if hasattr(user, "SoundsPlan") else 0
487
+ )
488
+ uuid_str = str(user.UUID) if hasattr(user, "UUID") else ""
489
+ plan_kind = classify_plan(
490
+ sounds_status=user.SoundsStatus,
491
+ sounds_plan=sounds_plan,
492
+ features=features,
493
+ )
494
+ creds = SpliceCredits(
495
+ credits=user.Credits,
496
+ username=user.Username,
497
+ plan=user.SoundsStatus,
498
+ sounds_plan_id=sounds_plan,
499
+ features=features,
500
+ plan_kind=plan_kind,
501
+ user_uuid=uuid_str,
309
502
  )
503
+ self._cached_credits = creds
504
+ return creds
310
505
  except Exception as exc:
311
506
  logger.warning(f"Credit check failed: {exc}")
312
507
  return SpliceCredits()
313
508
 
314
509
  async def can_afford(self, credits_needed: int, budget: int) -> tuple[bool, int]:
315
- """Check if we can afford credits_needed within budget.
510
+ """Check if we can afford `credits_needed` within `budget` for
511
+ credit-metered plans.
316
512
 
317
- Returns (can_afford, credits_remaining).
513
+ Returns (can_afford, credits_remaining). NOTE: does NOT consult the
514
+ daily quota — callers on the Ableton Live plan should use
515
+ `decide_download()` instead of `can_afford()`.
318
516
  """
319
517
  info = await self.get_credits()
320
518
  remaining = info.credits
@@ -341,6 +539,254 @@ class SpliceGRPCClient:
341
539
  except Exception as exc:
342
540
  logger.debug("sync_sounds failed: %s", exc)
343
541
  return False
542
+
543
+ # ── Collections ─────────────────────────────────────────────────
544
+
545
+ def _parse_collection(self, c) -> SpliceCollection:
546
+ creator_username = ""
547
+ try:
548
+ creator_username = c.Creator.Username
549
+ except AttributeError:
550
+ pass
551
+ access_map = {0: "unspecified", 1: "private", 2: "public"}
552
+ access = access_map.get(
553
+ int(c.Access) if hasattr(c, "Access") else 0, "unspecified",
554
+ )
555
+ return SpliceCollection(
556
+ uuid=c.UUID,
557
+ name=c.Name,
558
+ description=c.Description,
559
+ access=access,
560
+ permalink=c.Permalink,
561
+ cover_url=c.CoverURL,
562
+ sample_count=int(c.SampleCount),
563
+ preset_count=int(c.PresetCount),
564
+ pack_count=int(c.PackCount),
565
+ subscription_count=int(c.SubscriptionCount),
566
+ created_by_current_user=bool(c.CreatedByCurrentUser),
567
+ creator_username=creator_username,
568
+ created_at=c.CreatedAt,
569
+ updated_at=c.UpdatedAt,
570
+ )
571
+
572
+ async def list_collections(
573
+ self, page: int = 1, per_page: int = 50,
574
+ ) -> tuple[int, list[SpliceCollection]]:
575
+ """List the user's collections. Returns (total_count, collections)."""
576
+ if not self.connected:
577
+ return 0, []
578
+ pb2 = self._pb2
579
+ try:
580
+ response = await self.stub.CollectionsList(
581
+ pb2.CollectionsListRequest(Page=page, PerPage=per_page),
582
+ timeout=COLLECTION_TIMEOUT,
583
+ )
584
+ total = int(response.TotalCount)
585
+ collections = [self._parse_collection(c) for c in response.Collections]
586
+ return total, collections
587
+ except Exception as exc:
588
+ logger.warning(f"CollectionsList failed: {exc}")
589
+ return 0, []
590
+
591
+ async def collection_samples(
592
+ self, uuid: str, page: int = 1, per_page: int = 50,
593
+ ) -> tuple[int, list[SpliceSample]]:
594
+ """List samples inside a collection. Returns (total_hits, samples)."""
595
+ if not self.connected:
596
+ return 0, []
597
+ pb2 = self._pb2
598
+ try:
599
+ response = await self.stub.CollectionListSamples(
600
+ pb2.CollectionListSamplesRequest(
601
+ UUID=uuid, Page=page, PerPage=per_page,
602
+ ),
603
+ timeout=COLLECTION_TIMEOUT,
604
+ )
605
+ total = int(response.TotalHits)
606
+ samples = [self._parse_sample(s) for s in response.Samples]
607
+ return total, samples
608
+ except Exception as exc:
609
+ logger.warning(f"CollectionListSamples failed: {exc}")
610
+ return 0, []
611
+
612
+ async def add_to_collection(self, uuid: str, sample_hashes: list[str]) -> bool:
613
+ """Add samples to a collection. Returns True on success."""
614
+ if not self.connected or not sample_hashes:
615
+ return False
616
+ pb2 = self._pb2
617
+ try:
618
+ await self.stub.CollectionAddItems(
619
+ pb2.CollectionAddItemsRequest(UUID=uuid, Samples=sample_hashes),
620
+ timeout=COLLECTION_TIMEOUT,
621
+ )
622
+ return True
623
+ except Exception as exc:
624
+ logger.warning(f"CollectionAddItems failed: {exc}")
625
+ return False
626
+
627
+ async def remove_from_collection(
628
+ self, uuid: str, sample_hashes: list[str],
629
+ ) -> bool:
630
+ """Remove samples from a collection."""
631
+ if not self.connected or not sample_hashes:
632
+ return False
633
+ pb2 = self._pb2
634
+ try:
635
+ await self.stub.CollectionDeleteItems(
636
+ pb2.CollectionDeleteItemsRequest(UUID=uuid, Samples=sample_hashes),
637
+ timeout=COLLECTION_TIMEOUT,
638
+ )
639
+ return True
640
+ except Exception as exc:
641
+ logger.warning(f"CollectionDeleteItems failed: {exc}")
642
+ return False
643
+
644
+ async def create_collection(self, name: str) -> Optional[SpliceCollection]:
645
+ """Create a new user collection. Returns the new Collection or None."""
646
+ if not self.connected:
647
+ return None
648
+ pb2 = self._pb2
649
+ try:
650
+ response = await self.stub.CollectionAdd(
651
+ pb2.CollectionAddRequest(Name=name),
652
+ timeout=COLLECTION_TIMEOUT,
653
+ )
654
+ return self._parse_collection(response.Collection)
655
+ except Exception as exc:
656
+ logger.warning(f"CollectionAdd failed: {exc}")
657
+ return None
658
+
659
+ # ── Packs ───────────────────────────────────────────────────────
660
+
661
+ async def get_pack_info(self, pack_uuid: str) -> Optional[SplicePack]:
662
+ """Fetch metadata for a single sample pack."""
663
+ if not self.connected:
664
+ return None
665
+ pb2 = self._pb2
666
+ try:
667
+ response = await self.stub.SamplePackInfo(
668
+ pb2.SamplePackInfoRequest(UUID=pack_uuid),
669
+ timeout=INFO_TIMEOUT,
670
+ )
671
+ p = response.Pack
672
+ return SplicePack(
673
+ uuid=p.UUID,
674
+ name=p.Name,
675
+ cover_url=p.CoverURL,
676
+ genre=p.Genre,
677
+ permalink=p.Permalink,
678
+ provider_name=p.ProviderName,
679
+ )
680
+ except Exception as exc:
681
+ logger.warning(f"SamplePackInfo failed: {exc}")
682
+ return None
683
+
684
+ # ── Presets ─────────────────────────────────────────────────────
685
+
686
+ def _parse_preset(self, p) -> SplicePreset:
687
+ return SplicePreset(
688
+ uuid=p.UUID,
689
+ file_hash=p.FileHash,
690
+ filename=p.Filename,
691
+ local_path=p.LocalPath,
692
+ tags=list(p.Tags),
693
+ price=int(p.Price),
694
+ is_default=bool(p.IsDefault),
695
+ plugin_name=p.PluginName,
696
+ plugin_version=p.PluginVersion,
697
+ provider_name=p.ProviderName,
698
+ pack_uuid=p.Pack.UUID if hasattr(p, "Pack") else "",
699
+ preview_url=p.PreviewURL,
700
+ purchased_at=int(p.PurchasedAt) if hasattr(p, "PurchasedAt") else 0,
701
+ )
702
+
703
+ async def list_purchased_presets(
704
+ self,
705
+ page: int = 1,
706
+ per_page: int = 50,
707
+ sort: str = "",
708
+ sort_order: str = "",
709
+ ) -> tuple[int, list[SplicePreset]]:
710
+ """List presets the user has purchased/owns."""
711
+ if not self.connected:
712
+ return 0, []
713
+ pb2 = self._pb2
714
+ try:
715
+ response = await self.stub.PresetsListPurchased(
716
+ pb2.PresetsListPurchasedRequest(
717
+ Page=page, PerPage=per_page,
718
+ SortFn=sort, SortOrder=sort_order,
719
+ ),
720
+ timeout=PRESET_TIMEOUT,
721
+ )
722
+ total = int(response.TotalHits)
723
+ presets = [self._parse_preset(p) for p in response.Presets]
724
+ return total, presets
725
+ except Exception as exc:
726
+ logger.warning(f"PresetsListPurchased failed: {exc}")
727
+ return 0, []
728
+
729
+ async def get_preset_info(
730
+ self, uuid: str = "", file_hash: str = "", plugin_name: str = "",
731
+ ) -> Optional[dict]:
732
+ """Fetch metadata for a single preset."""
733
+ if not self.connected:
734
+ return None
735
+ pb2 = self._pb2
736
+ try:
737
+ response = await self.stub.PresetInfo(
738
+ pb2.PresetInfoRequest(
739
+ UUID=uuid, FileHash=file_hash, PluginName=plugin_name,
740
+ ),
741
+ timeout=PRESET_TIMEOUT,
742
+ )
743
+ return {
744
+ "uuid": response.Preset.UUID,
745
+ "file_hash": response.Preset.FileHash,
746
+ "local_path": response.Preset.LocalPath,
747
+ }
748
+ except Exception as exc:
749
+ logger.warning(f"PresetInfo failed: {exc}")
750
+ return None
751
+
752
+ async def download_preset(self, uuid: str) -> bool:
753
+ """Trigger a preset download (uses credits)."""
754
+ if not self.connected:
755
+ return False
756
+ pb2 = self._pb2
757
+ try:
758
+ await self.stub.PresetDownload(
759
+ pb2.PresetDownloadRequest(UUID=uuid),
760
+ timeout=PRESET_TIMEOUT,
761
+ )
762
+ return True
763
+ except Exception as exc:
764
+ logger.warning(f"PresetDownload failed: {exc}")
765
+ return False
766
+
767
+ # ── Convert to WAV ──────────────────────────────────────────────
768
+
769
+ async def convert_to_wav(self, path: str) -> Optional[dict]:
770
+ """Convert an audio file to PCM WAV via Splice's converter."""
771
+ if not self.connected:
772
+ return None
773
+ pb2 = self._pb2
774
+ try:
775
+ response = await self.stub.ConvertToWav(
776
+ pb2.ConvertToWavRequest(Path=path),
777
+ timeout=CONVERT_TIMEOUT,
778
+ )
779
+ wav = response.WavFile
780
+ return {
781
+ "path": wav.Path,
782
+ "channels": int(wav.Channels),
783
+ "sample_rate": int(wav.SampleRate),
784
+ "bit_depth": int(wav.BitDepth),
785
+ }
786
+ except Exception as exc:
787
+ logger.warning(f"ConvertToWav failed: {exc}")
788
+ return None
789
+
344
790
  # ── Connection Helpers ──────────────────────────────────────────
345
791
 
346
792
  def _read_port(self) -> Optional[int]: