forked from NeptuneHub/AudioMuse-AI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp_voyager.py
More file actions
378 lines (339 loc) · 13.3 KB
/
app_voyager.py
File metadata and controls
378 lines (339 loc) · 13.3 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
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
# app_voyager.py
from flask import Blueprint, jsonify, request, render_template
import logging
# Import the new config option
from config import SIMILARITY_ELIMINATE_DUPLICATES_DEFAULT, SIMILARITY_RADIUS_DEFAULT
from tasks.voyager_manager import (
find_nearest_neighbors_by_id,
get_max_distance_for_id,
create_playlist_from_ids,
search_tracks_by_title_and_artist,
get_item_id_by_title_and_artist
)
logger = logging.getLogger(__name__)
# Create a Blueprint for Voyager (similarity) related routes
voyager_bp = Blueprint('voyager_bp', __name__, template_folder='../templates')
@voyager_bp.route('/similarity', methods=['GET'])
def similarity_page():
"""
Serves the frontend page for finding similar tracks.
---
tags:
- UI
responses:
200:
description: HTML content of the similarity page.
content:
text/html:
schema:
type: string
"""
return render_template('similarity.html', title = 'AudioMuse-AI - Playlist from Similar Song', active='similarity')
@voyager_bp.route('/api/search_tracks', methods=['GET'])
def search_tracks_endpoint():
"""
Provides autocomplete suggestions for tracks based on title and artist.
---
tags:
- Similarity
parameters:
- name: title
in: query
description: Partial or full title of the track.
schema:
type: string
- name: artist
in: query
description: Partial or full name of the artist.
schema:
type: string
responses:
200:
description: A list of matching tracks.
content:
application/json:
schema:
type: array
items:
type: object
properties:
item_id:
type: string
title:
type: string
author:
type: string
album:
type: string
description: Album name or 'unknown' if missing
"""
title_query = request.args.get('title', '', type=str)
artist_query = request.args.get('artist', '', type=str)
if not title_query and not artist_query:
return jsonify([])
if len(title_query) < 3 and len(artist_query) < 3:
return jsonify([])
try:
raw_results = search_tracks_by_title_and_artist(title_query, artist_query)
results = []
for r in raw_results:
# Be defensive in case the source returns non-dict entries
if isinstance(r, dict):
album = (r.get('album') or '').strip() or 'unknown'
results.append({
'item_id': r.get('item_id'),
'title': r.get('title'),
'author': r.get('author'),
'album': album
})
else:
results.append({'item_id': None, 'title': None, 'author': None, 'album': 'unknown'})
return jsonify(results)
except Exception as e:
logger.error(f"Error during track search: {e}", exc_info=True)
return jsonify({"error": "An error occurred during search."}), 500
@voyager_bp.route('/api/similar_tracks', methods=['GET'])
def get_similar_tracks_endpoint():
"""
Find similar tracks for a given track, identified either by item_id or title/artist.
---
tags:
- Similarity
parameters:
- name: item_id
in: query
description: The media server Item ID of the track. Use this OR title/artist.
schema:
type: string
- name: title
in: query
description: The title of the track. Must be used with 'artist'.
schema:
type: string
- name: artist
in: query
description: The artist of the track. Must be used with 'title'.
schema:
type: string
- name: n
in: query
description: The number of similar tracks to return.
schema:
type: integer
default: 10
- name: eliminate_duplicates
in: query
description: If 'true', limits the number of songs per artist in the results. If 'false', this is disabled. If the parameter is omitted, the server's default behavior is used.
schema:
type: string
enum: ['true', 'false']
- name: mood_similarity
in: query
description: If 'true', filters results by mood similarity using stored mood features (danceability, aggressive, happy, party, relaxed, sad). If 'false', only acoustic similarity is used. Defaults to 'true' if omitted.
schema:
type: string
enum: ['true', 'false']
responses:
200:
description: A list of similar tracks with their details.
content:
application/json:
schema:
type: array
items:
type: object
properties:
item_id:
type: string
title:
type: string
author:
type: string
album:
type: string
description: Album name or 'unknown' if missing
distance:
type: number
400:
description: Bad request, missing required parameters.
404:
description: Target track not found.
500:
description: Server error.
"""
item_id = request.args.get('item_id')
title = request.args.get('title')
artist = request.args.get('artist')
num_neighbors = request.args.get('n', 10, type=int)
eliminate_duplicates_str = request.args.get('eliminate_duplicates')
if eliminate_duplicates_str is None:
eliminate_duplicates = SIMILARITY_ELIMINATE_DUPLICATES_DEFAULT
else:
eliminate_duplicates = eliminate_duplicates_str.lower() == 'true'
radius_similarity_str = request.args.get('radius_similarity')
if radius_similarity_str is None:
# Use configured default when parameter is omitted
radius_similarity = SIMILARITY_RADIUS_DEFAULT
else:
radius_similarity = radius_similarity_str.lower() == 'true'
mood_similarity_str = request.args.get('mood_similarity')
if mood_similarity_str is None:
mood_similarity = None # Respect config default when parameter is omitted
else:
mood_similarity = mood_similarity_str.lower() == 'true'
target_item_id = None
if item_id:
target_item_id = item_id
elif title and artist:
resolved_id = get_item_id_by_title_and_artist(title, artist)
if not resolved_id:
return jsonify({"error": f"Track '{title}' by '{artist}' not found in the database."}), 404
target_item_id = resolved_id
else:
return jsonify({"error": "Request must include either 'item_id' or both 'title' and 'artist'."}), 400
try:
neighbor_results = find_nearest_neighbors_by_id(
target_item_id,
n=num_neighbors,
eliminate_duplicates=eliminate_duplicates,
mood_similarity=mood_similarity,
radius_similarity=radius_similarity
)
if not neighbor_results:
return jsonify({"error": "Target track not found in index or no similar tracks found."}), 404
from app import get_score_data_by_ids
neighbor_ids = [n['item_id'] for n in neighbor_results]
neighbor_details = get_score_data_by_ids(neighbor_ids)
details_map = {d['item_id']: d for d in neighbor_details}
distance_map = {n['item_id']: n['distance'] for n in neighbor_results}
final_results = []
for neighbor_id in neighbor_ids:
if neighbor_id in details_map:
track_info = details_map[neighbor_id]
final_results.append({
"item_id": track_info['item_id'],
"title": track_info['title'],
"author": track_info['author'],
"album": (track_info.get('album') or 'unknown'),
"distance": distance_map[neighbor_id]
})
return jsonify(final_results)
except RuntimeError as e:
logger.error(f"Runtime error finding neighbors for {target_item_id}: {e}", exc_info=True)
return jsonify({"error": "The similarity search service is currently unavailable."}), 503
except Exception as e:
logger.error(f"Unexpected error finding neighbors for {target_item_id}: {e}", exc_info=True)
return jsonify({"error": "An unexpected error occurred."}), 500
@voyager_bp.route('/api/max_distance', methods=['GET'])
def get_max_distance_endpoint():
"""
Returns the exact maximum distance from the provided item_id to any other item in the index.
Query param: item_id (required)
Response: { "max_distance": float, "farthest_item_id": str | null }
"""
item_id = request.args.get('item_id')
if not item_id:
return jsonify({"error": "Missing 'item_id' parameter."}), 400
try:
result = get_max_distance_for_id(item_id)
if result is None:
return jsonify({"error": f"Item '{item_id}' not found in index or index unavailable."}), 404
return jsonify(result)
except RuntimeError as e:
logger.error(f"Runtime error computing max distance for {item_id}: {e}", exc_info=True)
return jsonify({"error": "The similarity search service is currently unavailable."}), 503
except Exception as e:
logger.error(f"Unexpected error computing max distance for {item_id}: {e}", exc_info=True)
return jsonify({"error": "An unexpected error occurred."}), 500
@voyager_bp.route('/api/track', methods=['GET'])
def get_track_endpoint():
"""
Fetch basic track metadata (title, author) for a given item_id.
Query param: item_id (required)
Response: { "item_id": str, "title": str, "author": str, "album": str } or 404
"""
item_id = request.args.get('item_id')
if not item_id:
return jsonify({"error": "Missing 'item_id' parameter."}), 400
try:
from app import get_score_data_by_ids
details = get_score_data_by_ids([item_id])
if not details:
return jsonify({"error": f"Item '{item_id}' not found."}), 404
# Return only the basic fields
d = details[0]
return jsonify({
"item_id": d.get('item_id'),
"title": d.get('title'),
"author": d.get('author'),
"album": (d.get('album') or 'unknown')
}), 200
except Exception as e:
logger.error(f"Unexpected error fetching track {item_id}: {e}", exc_info=True)
return jsonify({"error": "An unexpected error occurred."}), 500
@voyager_bp.route('/api/create_playlist', methods=['POST'])
def create_media_server_playlist():
"""
Creates a new playlist in the configured media server with the provided tracks.
---
tags:
- Similarity
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
playlist_name:
type: string
description: The name for the new playlist.
track_ids:
type: array
items:
type: string
description: A list of track Item IDs to add to the playlist.
responses:
201:
description: Playlist created successfully.
400:
description: Bad request, invalid payload.
500:
description: Server error during playlist creation.
"""
data = request.get_json()
if not data:
return jsonify({"error": "Invalid JSON payload"}), 400
# Debug log incoming payload to help trace client/server mismatch
try:
logger.info(f"/api/create_playlist called with payload: {data}")
except Exception:
logger.info('/api/create_playlist called (unable to serialize payload)')
playlist_name = data.get('playlist_name')
track_ids_raw = data.get('track_ids', [])
if not playlist_name:
return jsonify({"error": "Missing 'playlist_name'"}), 400
final_track_ids = []
if isinstance(track_ids_raw, list):
for item in track_ids_raw:
item_id = None
if isinstance(item, str):
item_id = item
elif isinstance(item, dict) and 'item_id' in item:
item_id = item['item_id']
if item_id and item_id not in final_track_ids:
final_track_ids.append(item_id)
if not final_track_ids:
return jsonify({"error": "No valid track IDs were provided to create the playlist"}), 400
# Optional user credentials may be provided by the client (e.g., from the Sonic Fingerprint UI)
user_creds = data.get('user_creds') if isinstance(data, dict) else None
try:
new_playlist_id = create_playlist_from_ids(playlist_name, final_track_ids, user_creds=user_creds)
logger.info(f"Successfully created playlist '{playlist_name}' with ID {new_playlist_id}.")
return jsonify({
"message": f"Playlist '{playlist_name}' created successfully!",
"playlist_id": new_playlist_id
}), 201
except Exception as e:
logger.error(f"Failed to create media server playlist '{playlist_name}': {e}", exc_info=True)
return jsonify({"error": "An error occurred while creating the playlist on the media server."}), 500