-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathsonos_helpers.py
More file actions
360 lines (281 loc) · 12.2 KB
/
sonos_helpers.py
File metadata and controls
360 lines (281 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
"""
Helper functions for Claude Code integration with Sonos CLI
"""
import re
import subprocess
from typing import List, Tuple, Optional, Dict, Any
from difflib import SequenceMatcher
def parse_search_results(search_output: str) -> List[Tuple[int, str, str, str]]:
"""
Parse sonos searchtrack output into structured data.
Args:
search_output: Raw output from sonos searchtrack command
Returns:
List of tuples: (position, title, artist, album)
"""
results = []
lines = search_output.strip().split('\n')
for line in lines:
# Match new pattern: "number. Title-Artist-Album"
match = re.match(r'^(\d+)\.\s+(.+?)-(.+?)-(.+)$', line.strip())
if match:
position = int(match.group(1))
title = match.group(2).strip()
artist = match.group(3).strip()
album = match.group(4).strip()
results.append((position, title, artist, album))
else:
# Fallback for old format without album
old_match = re.match(r'^(\d+)\.\s+(.+?)-(.+)$', line.strip())
if old_match:
position = int(old_match.group(1))
title = old_match.group(2).strip()
artist = old_match.group(3).strip()
results.append((position, title, artist, 'Unknown Album'))
return results
def clean_title(title: str) -> str:
"""
Clean track title by removing common variations and annotations.
Args:
title: Raw track title
Returns:
Cleaned title
"""
# Remove remaster annotations
title = re.sub(r'\s*\(\d{4}\s+Remaster\)', '', title, flags=re.IGNORECASE)
title = re.sub(r'\s*\(Remaster\)', '', title, flags=re.IGNORECASE)
title = re.sub(r'\s*\(Remastered\)', '', title, flags=re.IGNORECASE)
# Remove live annotations
title = re.sub(r'\s*\(Live.*?\)', '', title, flags=re.IGNORECASE)
title = re.sub(r'\s*- Live', '', title, flags=re.IGNORECASE)
# Remove explicit tags
title = re.sub(r'\s*\[Explicit\]', '', title, flags=re.IGNORECASE)
# Remove extra whitespace
title = re.sub(r'\s+', ' ', title).strip()
return title
def similarity_score(str1: str, str2: str) -> float:
"""
Calculate similarity score between two strings.
Args:
str1: First string
str2: Second string
Returns:
Similarity score between 0 and 1
"""
return SequenceMatcher(None, str1.lower(), str2.lower()).ratio()
def find_best_match(
search_results: List[Tuple[int, str, str, str]],
target_title: str,
target_artist: str = None,
preferences: Dict[str, Any] = None
) -> Optional[int]:
"""
Find the best matching track from search results.
Args:
search_results: List of (position, title, artist) tuples
target_title: Title to search for
target_artist: Optional artist to help with disambiguation
Returns:
Position of best match, or None if no good match found
"""
if not search_results:
return None
preferences = preferences or {}
prefer_live = preferences.get('prefer_live', False)
target_title_clean = clean_title(target_title).lower()
# Score each result
scored_results = []
for position, title, artist, album in search_results:
title_clean = clean_title(title).lower()
artist_clean = artist.lower()
album_clean = album.lower()
# Calculate title similarity
title_score = similarity_score(title_clean, target_title_clean)
# Bonus for exact match
if title_clean == target_title_clean:
title_score = 1.0
# Artist matching bonus
artist_score = 0.0
if target_artist:
target_artist_clean = target_artist.lower()
artist_score = similarity_score(artist_clean, target_artist_clean)
# Bonus for artist name being contained in result
if target_artist_clean in artist_clean or artist_clean in target_artist_clean:
artist_score = max(artist_score, 0.8)
# Handle version preferences - check both title and album
version_modifier = 0.0
is_live = bool(re.search(r'live', title, re.IGNORECASE)) or bool(re.search(r'live', album, re.IGNORECASE))
is_remaster = bool(re.search(r'remaster', title, re.IGNORECASE))
# Additional live detection patterns in album names
live_album_patterns = [
r'live from',
r'live at',
r'concert',
r'acoustic',
r'unplugged',
r'artists den'
]
if any(re.search(pattern, album_clean) for pattern in live_album_patterns):
is_live = True
if prefer_live:
# Boost live versions when specifically requested
if is_live:
version_modifier = 0.3 # Significant boost for live versions
elif is_remaster and title_score >= 0.9:
version_modifier = -0.1 # Small penalty for remasters when we want live
else:
# Original behavior - prefer studio versions
if title_score >= 0.9: # High title similarity
if is_remaster or is_live:
version_modifier = -0.1
# Combined score
if target_artist:
combined_score = (title_score * 0.7) + (artist_score * 0.3) + version_modifier
else:
combined_score = title_score + version_modifier
scored_results.append((position, combined_score, title, artist, album))
# Sort by score (highest first)
scored_results.sort(key=lambda x: x[1], reverse=True)
# Return position of best match if score is good enough
best_position, best_score, best_title, best_artist, best_album = scored_results[0]
# Only return if we have a reasonable match
if best_score >= 0.6:
return best_position
return None
def extract_music_request(user_input: str) -> Tuple[str, Optional[str], Dict[str, Any]]:
"""
Extract song title, artist, and preferences from natural language request.
Args:
user_input: Natural language music request
Returns:
Tuple of (title, artist, preferences) where artist may be None
"""
user_input_lower = user_input.strip().lower()
# Detect preferences
preferences = {}
# Detect live version requests
live_patterns = [
r'live version',
r'live recording',
r'live performance',
r'concert version',
r'\blive\b(?!.*studio)'
]
prefer_live = any(re.search(pattern, user_input_lower) for pattern in live_patterns)
if prefer_live:
preferences['prefer_live'] = True
# Clean the input for parsing (remove live-related modifiers)
clean_input = user_input_lower
clean_input = re.sub(r'\b(live version of|live recording of|live performance of|concert version of)\s*', '', clean_input)
clean_input = re.sub(r'\ba live version of\s*', '', clean_input)
clean_input = re.sub(r'\blive\b(?!.*studio)\s*', '', clean_input)
clean_input = re.sub(r'\s+', ' ', clean_input).strip()
# Common patterns
patterns = [
r'play\s+(.+?)\s+by\s+(.+)', # "play harvest by neil young"
r'(.+?)\s+by\s+(.+)', # "harvest by neil young"
r'play\s+(.+)', # "play harvest moon" (no artist)
]
for pattern in patterns:
match = re.search(pattern, clean_input, re.IGNORECASE)
if match:
if len(match.groups()) == 2:
return match.group(1).strip(), match.group(2).strip(), preferences
else:
return match.group(1).strip(), None, preferences
# Fallback: treat entire input as title
return clean_input, None, preferences
def execute_smart_search(title: str, artist: str = None, preferences: Dict[str, Any] = None) -> Optional[int]:
"""
Execute intelligent multi-search strategy to find the best track match.
Args:
title: Track title to search for
artist: Optional artist name
preferences: Search preferences (e.g., prefer_live)
Returns:
Position of best match, or None if no good match found
"""
preferences = preferences or {}
prefer_live = preferences.get('prefer_live', False)
# Generate search queries in order of preference
search_queries = []
if artist:
base_query = f"{title} by {artist}"
search_queries.append(base_query)
if prefer_live:
# Try live-specific searches first when requesting live versions
search_queries.insert(0, f"live {title} {artist}")
search_queries.insert(1, f"{title} live {artist}")
search_queries.insert(2, f"{artist} {title} live")
else:
base_query = title
search_queries.append(base_query)
if prefer_live:
search_queries.insert(0, f"live {title}")
search_queries.insert(1, f"{title} live")
best_match = None
best_score = 0
best_results = None
# Try each search query
for query in search_queries:
try:
# Execute sonos search
result = subprocess.run(
['sonos', 'searchtrack'] + query.split(),
capture_output=True,
text=True,
timeout=30
)
if result.returncode == 0 and result.stdout.strip():
# Parse results
results = parse_search_results(result.stdout)
if results:
# Find best match in these results
position = find_best_match(results, title, artist, preferences)
if position:
# Calculate a rough score for this match
for pos, track_title, track_artist, track_album in results:
if pos == position:
# Score based on title similarity and preference matching
title_sim = similarity_score(clean_title(track_title), title)
is_live = (bool(re.search(r'live', track_title, re.IGNORECASE)) or
bool(re.search(r'live', track_album, re.IGNORECASE)))
match_score = title_sim
if prefer_live and is_live:
match_score += 0.3
elif not prefer_live and not is_live:
match_score += 0.1
if match_score > best_score:
best_match = position
best_score = match_score
best_results = results
break
# If we found a great match (especially for live requests), stop searching
if best_score > 0.9 or (prefer_live and best_score > 0.7):
break
except (subprocess.TimeoutExpired, subprocess.SubprocessError):
continue
return best_match
def format_track_info(title: str, artist: str) -> str:
"""
Format track information for display.
Args:
title: Track title
artist: Track artist
Returns:
Formatted string
"""
return f"{title} by {artist}"
def handle_music_request(user_input: str) -> Optional[int]:
"""
Complete workflow to handle a music request from natural language input.
Args:
user_input: Natural language music request
Returns:
Position to play, or None if no good match found
"""
# Extract request components
title, artist, preferences = extract_music_request(user_input)
# Execute smart search
position = execute_smart_search(title, artist, preferences)
return position