openvoiceui 1.0.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.
- package/.env.example +104 -0
- package/Dockerfile +30 -0
- package/LICENSE +21 -0
- package/README.md +638 -0
- package/SETUP.md +360 -0
- package/app.py +232 -0
- package/auto-approve-devices.js +111 -0
- package/cli/index.js +372 -0
- package/config/__init__.py +4 -0
- package/config/default.yaml +43 -0
- package/config/flags.yaml +67 -0
- package/config/loader.py +203 -0
- package/config/providers.yaml +71 -0
- package/config/speech_normalization.yaml +182 -0
- package/config/theme.json +4 -0
- package/data/greetings.json +25 -0
- package/default-pages/ai-image-creator.html +915 -0
- package/default-pages/bulk-image-uploader.html +492 -0
- package/default-pages/desktop.html +2865 -0
- package/default-pages/file-explorer.html +854 -0
- package/default-pages/interactive-map.html +655 -0
- package/default-pages/style-guide.html +1005 -0
- package/default-pages/website-setup.html +1623 -0
- package/deploy/openclaw/Dockerfile +46 -0
- package/deploy/openvoiceui.service +30 -0
- package/deploy/setup-nginx.sh +50 -0
- package/deploy/setup-sudo.sh +306 -0
- package/deploy/skill-runner/Dockerfile +19 -0
- package/deploy/skill-runner/requirements.txt +14 -0
- package/deploy/skill-runner/server.py +269 -0
- package/deploy/supertonic/Dockerfile +22 -0
- package/deploy/supertonic/server.py +79 -0
- package/docker-compose.pinokio.yml +11 -0
- package/docker-compose.yml +59 -0
- package/greetings.json +25 -0
- package/index.html +65 -0
- package/inject-device-identity.js +142 -0
- package/package.json +82 -0
- package/profiles/default.json +114 -0
- package/profiles/manager.py +354 -0
- package/profiles/schema.json +337 -0
- package/prompts/voice-system-prompt.md +149 -0
- package/providers/__init__.py +39 -0
- package/providers/base.py +63 -0
- package/providers/llm/__init__.py +12 -0
- package/providers/llm/base.py +71 -0
- package/providers/llm/clawdbot_provider.py +112 -0
- package/providers/llm/zai_provider.py +115 -0
- package/providers/registry.py +320 -0
- package/providers/stt/__init__.py +12 -0
- package/providers/stt/base.py +58 -0
- package/providers/stt/webspeech_provider.py +49 -0
- package/providers/stt/whisper_provider.py +100 -0
- package/providers/tts/__init__.py +20 -0
- package/providers/tts/base.py +91 -0
- package/providers/tts/groq_provider.py +74 -0
- package/providers/tts/supertonic_provider.py +72 -0
- package/requirements.txt +38 -0
- package/routes/__init__.py +10 -0
- package/routes/admin.py +515 -0
- package/routes/canvas.py +1315 -0
- package/routes/chat.py +51 -0
- package/routes/conversation.py +2158 -0
- package/routes/elevenlabs_hybrid.py +306 -0
- package/routes/greetings.py +98 -0
- package/routes/icons.py +279 -0
- package/routes/image_gen.py +364 -0
- package/routes/instructions.py +190 -0
- package/routes/music.py +838 -0
- package/routes/onboarding.py +43 -0
- package/routes/pi.py +62 -0
- package/routes/profiles.py +215 -0
- package/routes/report_issue.py +68 -0
- package/routes/static_files.py +533 -0
- package/routes/suno.py +664 -0
- package/routes/theme.py +81 -0
- package/routes/transcripts.py +199 -0
- package/routes/vision.py +348 -0
- package/routes/workspace.py +288 -0
- package/server.py +1510 -0
- package/services/__init__.py +1 -0
- package/services/auth.py +143 -0
- package/services/canvas_versioning.py +239 -0
- package/services/db_pool.py +107 -0
- package/services/gateway.py +16 -0
- package/services/gateway_manager.py +333 -0
- package/services/gateways/__init__.py +12 -0
- package/services/gateways/base.py +110 -0
- package/services/gateways/compat.py +264 -0
- package/services/gateways/openclaw.py +1134 -0
- package/services/health.py +100 -0
- package/services/memory_client.py +455 -0
- package/services/paths.py +26 -0
- package/services/speech_normalizer.py +285 -0
- package/services/tts.py +270 -0
- package/setup-config.js +262 -0
- package/sounds/air_horn.mp3 +0 -0
- package/sounds/bruh.mp3 +0 -0
- package/sounds/crowd_cheer.mp3 +0 -0
- package/sounds/gunshot.mp3 +0 -0
- package/sounds/impact.mp3 +0 -0
- package/sounds/lets_go.mp3 +0 -0
- package/sounds/record_stop.mp3 +0 -0
- package/sounds/rewind.mp3 +0 -0
- package/sounds/sad_trombone.mp3 +0 -0
- package/sounds/scratch_long.mp3 +0 -0
- package/sounds/yeah.mp3 +0 -0
- package/src/adapters/ClawdBotAdapter.js +264 -0
- package/src/adapters/_template.js +133 -0
- package/src/adapters/elevenlabs-classic.js +841 -0
- package/src/adapters/elevenlabs-hybrid.js +812 -0
- package/src/adapters/hume-evi.js +676 -0
- package/src/admin.html +1339 -0
- package/src/app.js +8802 -0
- package/src/core/Config.js +173 -0
- package/src/core/EmotionEngine.js +307 -0
- package/src/core/EventBridge.js +180 -0
- package/src/core/EventBus.js +117 -0
- package/src/core/VoiceSession.js +607 -0
- package/src/face/BaseFace.js +259 -0
- package/src/face/EyeFace.js +208 -0
- package/src/face/HaloSmokeFace.js +509 -0
- package/src/face/manifest.json +27 -0
- package/src/face/previews/eyes.svg +16 -0
- package/src/face/previews/orb.svg +29 -0
- package/src/features/MusicPlayer.js +620 -0
- package/src/features/Soundboard.js +128 -0
- package/src/providers/DeepgramSTT.js +472 -0
- package/src/providers/DeepgramStreamingSTT.js +766 -0
- package/src/providers/GroqSTT.js +559 -0
- package/src/providers/TTSPlayer.js +323 -0
- package/src/providers/WebSpeechSTT.js +479 -0
- package/src/providers/tts/BaseTTSProvider.js +81 -0
- package/src/providers/tts/HumeProvider.js +77 -0
- package/src/providers/tts/SupertonicProvider.js +174 -0
- package/src/providers/tts/index.js +140 -0
- package/src/shell/adapter-registry.js +154 -0
- package/src/shell/caller-bridge.js +35 -0
- package/src/shell/camera-bridge.js +28 -0
- package/src/shell/canvas-bridge.js +32 -0
- package/src/shell/commercial-bridge.js +44 -0
- package/src/shell/face-bridge.js +44 -0
- package/src/shell/music-bridge.js +60 -0
- package/src/shell/orchestrator.js +233 -0
- package/src/shell/profile-discovery.js +303 -0
- package/src/shell/sounds-bridge.js +28 -0
- package/src/shell/transcript-bridge.js +61 -0
- package/src/shell/waveform-bridge.js +33 -0
- package/src/styles/base.css +2862 -0
- package/src/styles/face.css +417 -0
- package/src/styles/pi-overrides.css +89 -0
- package/src/styles/theme-dark.css +67 -0
- package/src/test-tts.html +175 -0
- package/src/ui/AppShell.js +544 -0
- package/src/ui/ProfileSwitcher.js +228 -0
- package/src/ui/SessionControl.js +240 -0
- package/src/ui/face/FacePicker.js +195 -0
- package/src/ui/face/FaceRenderer.js +309 -0
- package/src/ui/settings/PlaylistEditor.js +366 -0
- package/src/ui/settings/SettingsPanel.css +684 -0
- package/src/ui/settings/SettingsPanel.js +419 -0
- package/src/ui/settings/TTSVoicePreview.js +210 -0
- package/src/ui/themes/ThemeManager.js +213 -0
- package/src/ui/visualizers/BaseVisualizer.js +29 -0
- package/src/ui/visualizers/PartyFXVisualizer.css +291 -0
- package/src/ui/visualizers/PartyFXVisualizer.js +637 -0
- package/static/emulators/jsdos/js-dos.css +1 -0
- package/static/emulators/jsdos/js-dos.js +22 -0
- package/static/favicon.svg +55 -0
- package/static/icons/apple-touch-icon.png +0 -0
- package/static/icons/favicon-32.png +0 -0
- package/static/icons/icon-192.png +0 -0
- package/static/icons/icon-512.png +0 -0
- package/static/install.html +449 -0
- package/static/manifest.json +26 -0
- package/static/sw.js +21 -0
- package/tts_providers/__init__.py +136 -0
- package/tts_providers/base_provider.py +319 -0
- package/tts_providers/groq_provider.py +155 -0
- package/tts_providers/hume_provider.py +226 -0
- package/tts_providers/providers_config.json +119 -0
- package/tts_providers/qwen3_provider.py +371 -0
- package/tts_providers/resemble_provider.py +315 -0
- package/tts_providers/supertonic_provider.py +557 -0
- package/tts_providers/supertonic_tts.py +399 -0
package/routes/music.py
ADDED
|
@@ -0,0 +1,838 @@
|
|
|
1
|
+
"""
|
|
2
|
+
routes/music.py — Music System Blueprint (P2-T4)
|
|
3
|
+
|
|
4
|
+
Extracted from server.py during Phase 2 blueprint split.
|
|
5
|
+
Registers routes:
|
|
6
|
+
GET /music/<filename>
|
|
7
|
+
GET /generated_music/<filename>
|
|
8
|
+
GET /api/music (action: list|play|pause|resume|stop|skip|next|next_up|volume|status|shuffle|sync|confirm)
|
|
9
|
+
POST /api/music/transition (DJ transition pre-queue)
|
|
10
|
+
GET /api/music/transition (check pending transition)
|
|
11
|
+
POST /api/music/upload (upload a track)
|
|
12
|
+
GET /api/music/playlists (CRUD: list playlists with track counts)
|
|
13
|
+
DELETE /api/music/track/<playlist>/<filename> (CRUD: delete a track)
|
|
14
|
+
PUT /api/music/track/<playlist>/<filename>/metadata (CRUD: update track metadata)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import random
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from flask import Blueprint, jsonify, request, send_file
|
|
25
|
+
from routes.static_files import _safe_path
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Paths
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
from services.paths import MUSIC_DIR, GENERATED_MUSIC_DIR
|
|
32
|
+
|
|
33
|
+
MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
GENERATED_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# Shared music state (in-process; single-worker deployments only)
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_music_state_lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
current_music_state = {
|
|
43
|
+
"playing": False,
|
|
44
|
+
"current_track": None,
|
|
45
|
+
"volume": 0.3, # 0.0 – 1.0
|
|
46
|
+
"queue": [],
|
|
47
|
+
"shuffle": False,
|
|
48
|
+
"track_started_at": None,
|
|
49
|
+
"dj_transition_pending": False,
|
|
50
|
+
"next_track": None,
|
|
51
|
+
# Track reservation — prevents race conditions between tool calls and text detection
|
|
52
|
+
"reserved_track": None,
|
|
53
|
+
"reserved_at": None,
|
|
54
|
+
"reservation_id": None,
|
|
55
|
+
"current_playlist": "library", # 'library' | 'generated'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Reservation helpers
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def reserve_track(track):
|
|
63
|
+
"""Reserve a track the agent has announced; expires after 30 s."""
|
|
64
|
+
with _music_state_lock:
|
|
65
|
+
current_music_state["reserved_track"] = track
|
|
66
|
+
current_music_state["reserved_at"] = time.time()
|
|
67
|
+
current_music_state["reservation_id"] = str(uuid.uuid4())[:8]
|
|
68
|
+
rid = current_music_state["reservation_id"]
|
|
69
|
+
print(f"🎵 Track reserved: {track.get('name', 'Unknown')} (ID: {rid})")
|
|
70
|
+
return rid
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_reserved_track():
|
|
74
|
+
"""Return the reserved track if still valid (30-second window)."""
|
|
75
|
+
with _music_state_lock:
|
|
76
|
+
if not current_music_state.get("reserved_track"):
|
|
77
|
+
return None
|
|
78
|
+
reserved_at = current_music_state.get("reserved_at", 0)
|
|
79
|
+
if time.time() - reserved_at > 30:
|
|
80
|
+
print(f"🎵 Track reservation expired (was {current_music_state['reserved_track'].get('name', 'Unknown')})")
|
|
81
|
+
current_music_state["reserved_track"] = None
|
|
82
|
+
current_music_state["reserved_at"] = None
|
|
83
|
+
current_music_state["reservation_id"] = None
|
|
84
|
+
return None
|
|
85
|
+
return current_music_state["reserved_track"]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def clear_reservation():
|
|
89
|
+
"""Clear the active reservation (called when frontend confirms playback)."""
|
|
90
|
+
with _music_state_lock:
|
|
91
|
+
if current_music_state.get("reserved_track"):
|
|
92
|
+
print(f"🎵 Reservation cleared: {current_music_state['reserved_track'].get('name', 'Unknown')}")
|
|
93
|
+
current_music_state["reserved_track"] = None
|
|
94
|
+
current_music_state["reserved_at"] = None
|
|
95
|
+
current_music_state["reservation_id"] = None
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Metadata helpers
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def load_music_metadata():
|
|
102
|
+
"""Load library playlist metadata from JSON file."""
|
|
103
|
+
metadata_file = MUSIC_DIR / "music_metadata.json"
|
|
104
|
+
if metadata_file.exists():
|
|
105
|
+
try:
|
|
106
|
+
with open(metadata_file, "r") as f:
|
|
107
|
+
return json.load(f)
|
|
108
|
+
except Exception as e:
|
|
109
|
+
print(f"Error loading music metadata: {e}")
|
|
110
|
+
return {}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def load_generated_music_metadata():
|
|
114
|
+
"""Load AI-generated playlist metadata from JSON file."""
|
|
115
|
+
metadata_file = GENERATED_MUSIC_DIR / "generated_metadata.json"
|
|
116
|
+
if metadata_file.exists():
|
|
117
|
+
try:
|
|
118
|
+
with open(metadata_file, "r") as f:
|
|
119
|
+
return json.load(f)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"Error loading generated music metadata: {e}")
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def save_music_metadata(metadata):
|
|
126
|
+
"""Persist library playlist metadata (atomic write — safe against mid-write crashes)."""
|
|
127
|
+
metadata_file = MUSIC_DIR / "music_metadata.json"
|
|
128
|
+
tmp = metadata_file.with_suffix('.tmp')
|
|
129
|
+
tmp.write_text(json.dumps(metadata, indent=2))
|
|
130
|
+
tmp.replace(metadata_file)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def save_generated_music_metadata(metadata):
|
|
134
|
+
"""Persist AI-generated playlist metadata (atomic write — safe against mid-write crashes)."""
|
|
135
|
+
metadata_file = GENERATED_MUSIC_DIR / "generated_metadata.json"
|
|
136
|
+
tmp = metadata_file.with_suffix('.tmp')
|
|
137
|
+
tmp.write_text(json.dumps(metadata, indent=2))
|
|
138
|
+
tmp.replace(metadata_file)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def load_playlist_order(playlist):
|
|
142
|
+
"""Load saved track order for the given playlist (list of filenames)."""
|
|
143
|
+
music_dir = GENERATED_MUSIC_DIR if playlist == "generated" else MUSIC_DIR
|
|
144
|
+
order_file = music_dir / "order.json"
|
|
145
|
+
if order_file.exists():
|
|
146
|
+
try:
|
|
147
|
+
with open(order_file, "r") as f:
|
|
148
|
+
return json.load(f)
|
|
149
|
+
except Exception:
|
|
150
|
+
pass
|
|
151
|
+
return []
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def save_playlist_order(playlist, order):
|
|
155
|
+
"""Persist track order for the given playlist (atomic write)."""
|
|
156
|
+
music_dir = GENERATED_MUSIC_DIR if playlist == "generated" else MUSIC_DIR
|
|
157
|
+
order_file = music_dir / "order.json"
|
|
158
|
+
tmp = order_file.with_suffix('.tmp')
|
|
159
|
+
tmp.write_text(json.dumps(order, indent=2))
|
|
160
|
+
tmp.replace(order_file)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def get_music_files(playlist="library"):
|
|
164
|
+
"""
|
|
165
|
+
Return list of track dicts for the given playlist.
|
|
166
|
+
playlist: 'library' | 'generated' | 'spotify'
|
|
167
|
+
Respects saved order from order.json if present.
|
|
168
|
+
"""
|
|
169
|
+
if playlist == "spotify":
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
music_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
|
|
173
|
+
|
|
174
|
+
if playlist == "generated":
|
|
175
|
+
music_dir = GENERATED_MUSIC_DIR
|
|
176
|
+
metadata = load_generated_music_metadata()
|
|
177
|
+
url_prefix = "/generated_music/"
|
|
178
|
+
default_artist = "Jam-Bot"
|
|
179
|
+
else:
|
|
180
|
+
music_dir = MUSIC_DIR
|
|
181
|
+
metadata = load_music_metadata()
|
|
182
|
+
url_prefix = "/music/"
|
|
183
|
+
default_artist = "AI DJ"
|
|
184
|
+
|
|
185
|
+
files = []
|
|
186
|
+
for f in music_dir.iterdir():
|
|
187
|
+
if f.is_file() and f.suffix.lower() in music_extensions:
|
|
188
|
+
track_info = {
|
|
189
|
+
"filename": f.name,
|
|
190
|
+
"name": f.stem,
|
|
191
|
+
"size_bytes": f.stat().st_size,
|
|
192
|
+
"format": f.suffix.lower()[1:],
|
|
193
|
+
"url_prefix": url_prefix,
|
|
194
|
+
"playlist": playlist,
|
|
195
|
+
}
|
|
196
|
+
if f.name in metadata:
|
|
197
|
+
meta = metadata[f.name]
|
|
198
|
+
track_info.update({
|
|
199
|
+
"title": meta.get("title", f.stem),
|
|
200
|
+
"artist": meta.get("artist", default_artist),
|
|
201
|
+
"duration_seconds": meta.get("duration_seconds", 120),
|
|
202
|
+
"description": meta.get("description", ""),
|
|
203
|
+
"phone_number": meta.get("phone_number"),
|
|
204
|
+
"ad_copy": meta.get("ad_copy", ""),
|
|
205
|
+
"fun_facts": meta.get("fun_facts", []),
|
|
206
|
+
"genre": meta.get("genre", "Unknown"),
|
|
207
|
+
"energy": meta.get("energy", "medium"),
|
|
208
|
+
"dj_intro_hints": meta.get("dj_intro_hints", []),
|
|
209
|
+
})
|
|
210
|
+
else:
|
|
211
|
+
track_info.update({
|
|
212
|
+
"title": f.stem,
|
|
213
|
+
"artist": default_artist,
|
|
214
|
+
"duration_seconds": 120,
|
|
215
|
+
"description": "A track from the music library!" if playlist == "library" else "An AI-generated original!",
|
|
216
|
+
"phone_number": None,
|
|
217
|
+
"ad_copy": "",
|
|
218
|
+
"fun_facts": [],
|
|
219
|
+
"genre": "Unknown",
|
|
220
|
+
"energy": "medium",
|
|
221
|
+
"dj_intro_hints": [],
|
|
222
|
+
})
|
|
223
|
+
files.append(track_info)
|
|
224
|
+
|
|
225
|
+
# Apply saved order if present; unordered tracks fall to end alphabetically
|
|
226
|
+
saved_order = load_playlist_order(playlist)
|
|
227
|
+
if saved_order:
|
|
228
|
+
order_index = {name: i for i, name in enumerate(saved_order)}
|
|
229
|
+
fallback = len(saved_order)
|
|
230
|
+
files.sort(key=lambda x: (order_index.get(x["filename"], fallback), x["name"].lower()))
|
|
231
|
+
else:
|
|
232
|
+
files.sort(key=lambda x: x["name"].lower())
|
|
233
|
+
return files
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _build_dj_hints(track):
|
|
237
|
+
"""Build a DJ hints string from track metadata."""
|
|
238
|
+
title = track.get("title", track["name"])
|
|
239
|
+
description = track.get("description", "")
|
|
240
|
+
phone = track.get("phone_number")
|
|
241
|
+
ad_copy = track.get("ad_copy", "")
|
|
242
|
+
fun_facts = track.get("fun_facts", [])
|
|
243
|
+
duration = track.get("duration_seconds", 120)
|
|
244
|
+
duration_str = f"{int(duration // 60)}:{int(duration % 60):02d}"
|
|
245
|
+
|
|
246
|
+
hints = f"Title: {title}. Duration: {duration_str}."
|
|
247
|
+
if description:
|
|
248
|
+
hints += f" About: {description}"
|
|
249
|
+
if phone:
|
|
250
|
+
hints += f" Call: {phone}"
|
|
251
|
+
if ad_copy:
|
|
252
|
+
hints += f" Ad: {ad_copy}"
|
|
253
|
+
if fun_facts:
|
|
254
|
+
hints += f" Fun fact: {random.choice(fun_facts)}"
|
|
255
|
+
return hints
|
|
256
|
+
|
|
257
|
+
# ---------------------------------------------------------------------------
|
|
258
|
+
# Blueprint
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
|
|
261
|
+
music_bp = Blueprint("music", __name__)
|
|
262
|
+
|
|
263
|
+
# MIME type map shared by file-serving routes
|
|
264
|
+
_AUDIO_MIME_TYPES = {
|
|
265
|
+
".mp3": "audio/mpeg",
|
|
266
|
+
".wav": "audio/wav",
|
|
267
|
+
".ogg": "audio/ogg",
|
|
268
|
+
".m4a": "audio/mp4",
|
|
269
|
+
".webm": "audio/webm",
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@music_bp.route("/music/<filename>")
|
|
274
|
+
def serve_music_file(filename):
|
|
275
|
+
"""Serve library music files."""
|
|
276
|
+
music_path = _safe_path(MUSIC_DIR, filename)
|
|
277
|
+
if music_path is None or not music_path.exists():
|
|
278
|
+
return jsonify({"error": "Track not found"}), 404
|
|
279
|
+
mime_type = _AUDIO_MIME_TYPES.get(music_path.suffix.lower(), "audio/mpeg")
|
|
280
|
+
return send_file(music_path, mimetype=mime_type)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@music_bp.route("/generated_music/<filename>")
|
|
284
|
+
def serve_generated_music_file(filename):
|
|
285
|
+
"""Serve AI-generated music files."""
|
|
286
|
+
music_path = _safe_path(GENERATED_MUSIC_DIR, filename)
|
|
287
|
+
if music_path is None or not music_path.exists():
|
|
288
|
+
return jsonify({"error": "Generated track not found"}), 404
|
|
289
|
+
mime_type = _AUDIO_MIME_TYPES.get(music_path.suffix.lower(), "audio/mpeg")
|
|
290
|
+
return send_file(music_path, mimetype=mime_type)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@music_bp.route("/api/music", methods=["GET"])
|
|
294
|
+
def handle_music():
|
|
295
|
+
"""
|
|
296
|
+
All-in-one music endpoint.
|
|
297
|
+
Query params:
|
|
298
|
+
action : list | play | pause | resume | stop | skip | next | next_up |
|
|
299
|
+
volume | status | shuffle | sync | confirm
|
|
300
|
+
track : track name or filename (for play)
|
|
301
|
+
volume : 0-100 (for volume action)
|
|
302
|
+
playlist : 'library' | 'generated'
|
|
303
|
+
"""
|
|
304
|
+
action = request.args.get("action", "list")
|
|
305
|
+
track_param = request.args.get("track", "")
|
|
306
|
+
volume_param = request.args.get("volume", "")
|
|
307
|
+
playlist = request.args.get(
|
|
308
|
+
"playlist", current_music_state.get("current_playlist", "library")
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if playlist in ("library", "generated", "spotify"):
|
|
312
|
+
current_music_state["current_playlist"] = playlist
|
|
313
|
+
|
|
314
|
+
# ── SPOTIFY ───────────────────────────────────────────────────────────
|
|
315
|
+
# Handle before get_music_files — Spotify has no local files
|
|
316
|
+
if action == "spotify":
|
|
317
|
+
track = request.args.get("track", "Unknown Track")
|
|
318
|
+
artist = request.args.get("artist", "Spotify")
|
|
319
|
+
album = request.args.get("album", "")
|
|
320
|
+
current_music_state["current_playlist"] = "spotify"
|
|
321
|
+
current_music_state["playing"] = True
|
|
322
|
+
current_music_state["track_started_at"] = time.time()
|
|
323
|
+
spotify_track = {
|
|
324
|
+
"title": track,
|
|
325
|
+
"name": track,
|
|
326
|
+
"artist": artist,
|
|
327
|
+
"album": album,
|
|
328
|
+
"playlist": "spotify",
|
|
329
|
+
"source": "spotify",
|
|
330
|
+
"filename": None,
|
|
331
|
+
}
|
|
332
|
+
current_music_state["current_track"] = spotify_track
|
|
333
|
+
print(f"🎵 Spotify mode: '{track}' by {artist}")
|
|
334
|
+
return jsonify({
|
|
335
|
+
"action": "spotify",
|
|
336
|
+
"track": spotify_track,
|
|
337
|
+
"playlist": "spotify",
|
|
338
|
+
"source": "spotify",
|
|
339
|
+
"response": f"Now streaming '{track}' by {artist} from Spotify.",
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
music_files = get_music_files(playlist)
|
|
344
|
+
|
|
345
|
+
# ── LIST ──────────────────────────────────────────────────────────
|
|
346
|
+
if action == "list":
|
|
347
|
+
if playlist == "spotify":
|
|
348
|
+
return jsonify({
|
|
349
|
+
"tracks": [],
|
|
350
|
+
"count": 0,
|
|
351
|
+
"playlist": "spotify",
|
|
352
|
+
"available_playlists": ["library", "generated", "spotify"],
|
|
353
|
+
"source": "spotify",
|
|
354
|
+
"response": "Spotify streaming mode. Ask me to play any song, album, or playlist on Spotify.",
|
|
355
|
+
})
|
|
356
|
+
if not music_files:
|
|
357
|
+
return jsonify({
|
|
358
|
+
"tracks": [],
|
|
359
|
+
"count": 0,
|
|
360
|
+
"playlist": playlist,
|
|
361
|
+
"available_playlists": ["library", "generated", "spotify"],
|
|
362
|
+
"response": "I don't have any music yet! Upload some MP3s to my music folder and I'll spin them for you.",
|
|
363
|
+
})
|
|
364
|
+
track_names = [t["name"] for t in music_files]
|
|
365
|
+
return jsonify({
|
|
366
|
+
"tracks": music_files,
|
|
367
|
+
"count": len(music_files),
|
|
368
|
+
"playlist": playlist,
|
|
369
|
+
"available_playlists": ["library", "generated", "spotify"],
|
|
370
|
+
"response": (
|
|
371
|
+
f"I've got {len(music_files)} track{'s' if len(music_files) != 1 else ''} ready to spin: "
|
|
372
|
+
f"{', '.join(track_names[:5])}{'...' if len(track_names) > 5 else ''}"
|
|
373
|
+
),
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
# ── PLAY ──────────────────────────────────────────────────────────
|
|
377
|
+
elif action == "play":
|
|
378
|
+
if not music_files:
|
|
379
|
+
return jsonify({"action": "error", "response": "No music files! My DJ booth is empty. Get me some tunes!"})
|
|
380
|
+
|
|
381
|
+
selected = None
|
|
382
|
+
if track_param:
|
|
383
|
+
track_lower = track_param.lower()
|
|
384
|
+
# Normalize smart quotes to ASCII for matching
|
|
385
|
+
_quote_map = str.maketrans({'\u2018': "'", '\u2019': "'", '\u201c': '"', '\u201d': '"'})
|
|
386
|
+
track_norm = track_lower.translate(_quote_map)
|
|
387
|
+
for t in music_files:
|
|
388
|
+
t_name = t["name"].lower()
|
|
389
|
+
t_file = t["filename"].lower()
|
|
390
|
+
t_title = t.get("title", "").lower().translate(_quote_map)
|
|
391
|
+
if (track_norm in t_name
|
|
392
|
+
or track_norm in t_file
|
|
393
|
+
or track_norm in t_title):
|
|
394
|
+
selected = t
|
|
395
|
+
break
|
|
396
|
+
if not selected:
|
|
397
|
+
return jsonify({
|
|
398
|
+
"action": "error",
|
|
399
|
+
"response": f"Can't find a track matching '{track_param}'. Try 'list music' to see what I have.",
|
|
400
|
+
})
|
|
401
|
+
print(f"🎵 PLAY matched: query='{track_param}' → file='{selected['filename']}' title='{selected.get('title', '')}'")
|
|
402
|
+
else:
|
|
403
|
+
selected = random.choice(music_files)
|
|
404
|
+
|
|
405
|
+
current_music_state["playing"] = True
|
|
406
|
+
current_music_state["current_track"] = selected
|
|
407
|
+
current_music_state["track_started_at"] = time.time()
|
|
408
|
+
reservation_id = reserve_track(selected)
|
|
409
|
+
|
|
410
|
+
title = selected.get("title", selected["name"])
|
|
411
|
+
description = selected.get("description", "")
|
|
412
|
+
duration = selected.get("duration_seconds", 120)
|
|
413
|
+
|
|
414
|
+
return jsonify({
|
|
415
|
+
"action": "play",
|
|
416
|
+
"track": selected,
|
|
417
|
+
"url": f"{selected.get('url_prefix', '/music/')}{selected['filename']}",
|
|
418
|
+
"playlist": playlist,
|
|
419
|
+
"duration_seconds": duration,
|
|
420
|
+
"dj_hints": _build_dj_hints(selected),
|
|
421
|
+
"reservation_id": reservation_id,
|
|
422
|
+
"response": f"Now playing '{title}'! {description if description else 'Lets gooo!'}",
|
|
423
|
+
})
|
|
424
|
+
|
|
425
|
+
# ── PAUSE ─────────────────────────────────────────────────────────
|
|
426
|
+
elif action == "pause":
|
|
427
|
+
current_music_state["playing"] = False
|
|
428
|
+
track_name = (current_music_state.get("current_track") or {}).get("name", "the music")
|
|
429
|
+
return jsonify({"action": "pause", "response": f"Pausing {track_name}. Taking a breather."})
|
|
430
|
+
|
|
431
|
+
# ── RESUME ────────────────────────────────────────────────────────
|
|
432
|
+
elif action == "resume":
|
|
433
|
+
current_music_state["playing"] = True
|
|
434
|
+
track_name = (current_music_state.get("current_track") or {}).get("name", "the music")
|
|
435
|
+
return jsonify({"action": "resume", "response": f"Resuming {track_name}. Back on the air!"})
|
|
436
|
+
|
|
437
|
+
# ── STOP ──────────────────────────────────────────────────────────
|
|
438
|
+
elif action == "stop":
|
|
439
|
+
current_music_state["playing"] = False
|
|
440
|
+
current_music_state["current_track"] = None
|
|
441
|
+
return jsonify({"action": "stop", "response": "Music stopped. Silence... beautiful, terrible silence."})
|
|
442
|
+
|
|
443
|
+
# ── SKIP / NEXT ───────────────────────────────────────────────────
|
|
444
|
+
elif action in ("skip", "next"):
|
|
445
|
+
if not music_files:
|
|
446
|
+
return jsonify({"action": "error", "response": "No music to skip to!"})
|
|
447
|
+
|
|
448
|
+
current_name = (current_music_state.get("current_track") or {}).get("name")
|
|
449
|
+
available = [t for t in music_files if t["name"] != current_name] or music_files
|
|
450
|
+
selected = random.choice(available)
|
|
451
|
+
|
|
452
|
+
current_music_state["playing"] = True
|
|
453
|
+
current_music_state["current_track"] = selected
|
|
454
|
+
current_music_state["track_started_at"] = time.time()
|
|
455
|
+
reservation_id = reserve_track(selected)
|
|
456
|
+
|
|
457
|
+
title = selected.get("title", selected["name"])
|
|
458
|
+
description = selected.get("description", "")
|
|
459
|
+
duration = selected.get("duration_seconds", 120)
|
|
460
|
+
|
|
461
|
+
return jsonify({
|
|
462
|
+
"action": "play",
|
|
463
|
+
"track": selected,
|
|
464
|
+
"url": f"{selected.get('url_prefix', '/music/')}{selected['filename']}",
|
|
465
|
+
"playlist": playlist,
|
|
466
|
+
"duration_seconds": duration,
|
|
467
|
+
"dj_hints": _build_dj_hints(selected),
|
|
468
|
+
"reservation_id": reservation_id,
|
|
469
|
+
"response": f"Skipping! Next up: '{title}'! {description if description else ''}",
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
# ── NEXT_UP ───────────────────────────────────────────────────────
|
|
473
|
+
elif action == "next_up":
|
|
474
|
+
if not music_files:
|
|
475
|
+
return jsonify({"action": "error", "response": "No tracks available!"})
|
|
476
|
+
|
|
477
|
+
current_name = (current_music_state.get("current_track") or {}).get("name")
|
|
478
|
+
available = [t for t in music_files if t["name"] != current_name] or music_files
|
|
479
|
+
selected = random.choice(available)
|
|
480
|
+
current_music_state["next_track"] = selected
|
|
481
|
+
|
|
482
|
+
title = selected.get("title", selected["name"])
|
|
483
|
+
duration = selected.get("duration_seconds", 120)
|
|
484
|
+
|
|
485
|
+
return jsonify({
|
|
486
|
+
"action": "next_up",
|
|
487
|
+
"track": selected,
|
|
488
|
+
"duration_seconds": duration,
|
|
489
|
+
"dj_hints": _build_dj_hints(selected),
|
|
490
|
+
"response": f"Coming up next: '{title}'!",
|
|
491
|
+
})
|
|
492
|
+
|
|
493
|
+
# ── VOLUME ────────────────────────────────────────────────────────
|
|
494
|
+
elif action == "volume":
|
|
495
|
+
if not volume_param:
|
|
496
|
+
current_vol = int(current_music_state["volume"] * 100)
|
|
497
|
+
return jsonify({"action": "volume", "volume": current_vol, "response": f"Volume is at {current_vol}%."})
|
|
498
|
+
|
|
499
|
+
try:
|
|
500
|
+
new_vol = max(0, min(100, int(volume_param)))
|
|
501
|
+
current_music_state["volume"] = new_vol / 100
|
|
502
|
+
if new_vol >= 80:
|
|
503
|
+
comment = "Cranking it up! Let's make some noise!"
|
|
504
|
+
elif new_vol >= 50:
|
|
505
|
+
comment = "Nice and loud. I like it."
|
|
506
|
+
elif new_vol >= 20:
|
|
507
|
+
comment = "Background vibes. Got it."
|
|
508
|
+
else:
|
|
509
|
+
comment = "Barely a whisper. You sure you want music?"
|
|
510
|
+
return jsonify({"action": "volume", "volume": new_vol, "response": f"Volume set to {new_vol}%. {comment}"})
|
|
511
|
+
except ValueError:
|
|
512
|
+
return jsonify({"action": "error", "response": f"'{volume_param}' isn't a valid volume. Give me a number 0-100."})
|
|
513
|
+
|
|
514
|
+
# ── STATUS ────────────────────────────────────────────────────────
|
|
515
|
+
elif action == "status":
|
|
516
|
+
track = current_music_state.get("current_track")
|
|
517
|
+
playing = current_music_state.get("playing", False)
|
|
518
|
+
vol = int(current_music_state["volume"] * 100)
|
|
519
|
+
started_at = current_music_state.get("track_started_at")
|
|
520
|
+
|
|
521
|
+
# Spotify mode — no local file, no timeline
|
|
522
|
+
if track and track.get("source") == "spotify":
|
|
523
|
+
title = track.get("title", "Unknown")
|
|
524
|
+
artist = track.get("artist", "")
|
|
525
|
+
artist_str = f" by {artist}" if artist else ""
|
|
526
|
+
return jsonify({
|
|
527
|
+
"action": "status",
|
|
528
|
+
"playing": playing,
|
|
529
|
+
"track": track,
|
|
530
|
+
"source": "spotify",
|
|
531
|
+
"volume": vol,
|
|
532
|
+
"response": f"{'Streaming' if playing else 'Paused'}:'{title}'{artist_str} on Spotify.",
|
|
533
|
+
})
|
|
534
|
+
|
|
535
|
+
if track and playing:
|
|
536
|
+
duration = track.get("duration_seconds", 120)
|
|
537
|
+
elapsed = time.time() - started_at if started_at else 0
|
|
538
|
+
remaining = max(0, duration - elapsed)
|
|
539
|
+
title = track.get("title", track["name"])
|
|
540
|
+
return jsonify({
|
|
541
|
+
"action": "status",
|
|
542
|
+
"playing": True,
|
|
543
|
+
"track": track,
|
|
544
|
+
"volume": vol,
|
|
545
|
+
"duration_seconds": duration,
|
|
546
|
+
"elapsed_seconds": int(elapsed),
|
|
547
|
+
"remaining_seconds": int(remaining),
|
|
548
|
+
"response": f"Now playing: '{title}' at {vol}% volume. About {int(remaining)}s remaining.",
|
|
549
|
+
})
|
|
550
|
+
elif track:
|
|
551
|
+
title = track.get("title", track["name"])
|
|
552
|
+
return jsonify({
|
|
553
|
+
"action": "status",
|
|
554
|
+
"playing": False,
|
|
555
|
+
"track": track,
|
|
556
|
+
"volume": vol,
|
|
557
|
+
"response": f"'{title}' is paused. Volume at {vol}%.",
|
|
558
|
+
})
|
|
559
|
+
else:
|
|
560
|
+
return jsonify({
|
|
561
|
+
"action": "status",
|
|
562
|
+
"playing": False,
|
|
563
|
+
"track": None,
|
|
564
|
+
"volume": vol,
|
|
565
|
+
"response": "Nothing playing right now. Say 'play music' to get the party started!",
|
|
566
|
+
})
|
|
567
|
+
|
|
568
|
+
# ── SHUFFLE ───────────────────────────────────────────────────────
|
|
569
|
+
elif action == "shuffle":
|
|
570
|
+
current_music_state["shuffle"] = not current_music_state["shuffle"]
|
|
571
|
+
state = "on" if current_music_state["shuffle"] else "off"
|
|
572
|
+
return jsonify({
|
|
573
|
+
"action": "shuffle",
|
|
574
|
+
"shuffle": current_music_state["shuffle"],
|
|
575
|
+
"response": f"Shuffle is {state}. {'Random chaos enabled!' if current_music_state['shuffle'] else 'Back to order.'}",
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
# ── SYNC ──────────────────────────────────────────────────────────
|
|
579
|
+
elif action == "sync":
|
|
580
|
+
reserved = get_reserved_track()
|
|
581
|
+
if reserved:
|
|
582
|
+
title = reserved.get("title", reserved["name"])
|
|
583
|
+
duration = reserved.get("duration_seconds", 120)
|
|
584
|
+
print(f"🎵 SYNC returning reserved track: {title}")
|
|
585
|
+
return jsonify({
|
|
586
|
+
"action": "play",
|
|
587
|
+
"track": reserved,
|
|
588
|
+
"url": f"/music/{reserved['filename']}",
|
|
589
|
+
"duration_seconds": duration,
|
|
590
|
+
"reservation_id": current_music_state.get("reservation_id"),
|
|
591
|
+
"synced": True,
|
|
592
|
+
"response": f"Synced to '{title}'",
|
|
593
|
+
})
|
|
594
|
+
|
|
595
|
+
track = current_music_state.get("current_track")
|
|
596
|
+
if track and current_music_state.get("playing"):
|
|
597
|
+
title = track.get("title", track["name"])
|
|
598
|
+
duration = track.get("duration_seconds", 120)
|
|
599
|
+
print(f"🎵 SYNC returning current track: {title}")
|
|
600
|
+
return jsonify({
|
|
601
|
+
"action": "play",
|
|
602
|
+
"track": track,
|
|
603
|
+
"url": f"/music/{track['filename']}",
|
|
604
|
+
"duration_seconds": duration,
|
|
605
|
+
"synced": True,
|
|
606
|
+
"response": f"Synced to '{title}'",
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
print("🎵 SYNC: No track to sync")
|
|
610
|
+
return jsonify({"action": "none", "synced": True, "response": "No track to sync to"})
|
|
611
|
+
|
|
612
|
+
# ── CONFIRM ───────────────────────────────────────────────────────
|
|
613
|
+
elif action == "confirm":
|
|
614
|
+
res_id = request.args.get("reservation_id", "")
|
|
615
|
+
current_res_id = current_music_state.get("reservation_id", "")
|
|
616
|
+
if res_id and res_id == current_res_id:
|
|
617
|
+
track = current_music_state.get("reserved_track")
|
|
618
|
+
title = track.get("title", track["name"]) if track else "Unknown"
|
|
619
|
+
clear_reservation()
|
|
620
|
+
print(f"🎵 Playback confirmed for: {title}")
|
|
621
|
+
return jsonify({"action": "confirmed", "response": f"Playback confirmed: {title}"})
|
|
622
|
+
else:
|
|
623
|
+
return jsonify({"action": "error", "response": "Invalid or expired reservation"})
|
|
624
|
+
|
|
625
|
+
else:
|
|
626
|
+
return jsonify({
|
|
627
|
+
"action": "error",
|
|
628
|
+
"response": f"Unknown action '{action}'. Try: list, play, pause, stop, skip, volume, status, sync",
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
except Exception as e:
|
|
632
|
+
print(f"Music error: {e}")
|
|
633
|
+
return jsonify({"action": "error", "response": "Music playback error"})
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
@music_bp.route("/api/music/transition", methods=["POST", "GET"])
|
|
637
|
+
def handle_dj_transition():
|
|
638
|
+
"""
|
|
639
|
+
DJ transition endpoint.
|
|
640
|
+
POST: Frontend signals song is ending; pre-queue next track.
|
|
641
|
+
GET : Agent polls for pending transition.
|
|
642
|
+
"""
|
|
643
|
+
if request.method == "POST":
|
|
644
|
+
data = request.get_json() or {}
|
|
645
|
+
remaining = data.get("remaining_seconds", 10)
|
|
646
|
+
|
|
647
|
+
music_files = get_music_files()
|
|
648
|
+
current_name = (current_music_state.get("current_track") or {}).get("name")
|
|
649
|
+
available = [t for t in music_files if t["name"] != current_name] or music_files
|
|
650
|
+
|
|
651
|
+
if available:
|
|
652
|
+
selected = random.choice(available)
|
|
653
|
+
current_music_state["next_track"] = selected
|
|
654
|
+
current_music_state["dj_transition_pending"] = True
|
|
655
|
+
|
|
656
|
+
title = selected.get("title", selected["name"])
|
|
657
|
+
description = selected.get("description", "")
|
|
658
|
+
fun_facts = selected.get("fun_facts", [])
|
|
659
|
+
|
|
660
|
+
return jsonify({
|
|
661
|
+
"status": "transition_queued",
|
|
662
|
+
"next_track": selected,
|
|
663
|
+
"remaining_seconds": remaining,
|
|
664
|
+
"response": f"Coming up next: '{title}'! {random.choice(fun_facts) if fun_facts else description}",
|
|
665
|
+
})
|
|
666
|
+
else:
|
|
667
|
+
return jsonify({"status": "no_tracks", "response": "No more tracks to play!"})
|
|
668
|
+
|
|
669
|
+
else: # GET
|
|
670
|
+
if current_music_state.get("dj_transition_pending") and current_music_state.get("next_track"):
|
|
671
|
+
track = current_music_state["next_track"]
|
|
672
|
+
current_music_state["dj_transition_pending"] = False
|
|
673
|
+
|
|
674
|
+
title = track.get("title", track["name"])
|
|
675
|
+
duration = track.get("duration_seconds", 120)
|
|
676
|
+
|
|
677
|
+
return jsonify({
|
|
678
|
+
"transition_pending": True,
|
|
679
|
+
"next_track": track,
|
|
680
|
+
"dj_hints": _build_dj_hints(track),
|
|
681
|
+
"response": f"Hey! Song's ending soon. Coming up next: '{title}'!",
|
|
682
|
+
})
|
|
683
|
+
else:
|
|
684
|
+
return jsonify({"transition_pending": False, "response": "No transition pending."})
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
@music_bp.route("/api/music/upload", methods=["POST"])
|
|
688
|
+
def upload_music():
|
|
689
|
+
"""Upload a music file to the library playlist."""
|
|
690
|
+
if "file" not in request.files:
|
|
691
|
+
return jsonify({"error": "No file provided"}), 400
|
|
692
|
+
|
|
693
|
+
file = request.files["file"]
|
|
694
|
+
if not file.filename:
|
|
695
|
+
return jsonify({"error": "No filename"}), 400
|
|
696
|
+
|
|
697
|
+
allowed_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
|
|
698
|
+
ext = Path(file.filename).suffix.lower()
|
|
699
|
+
if ext not in allowed_extensions:
|
|
700
|
+
return jsonify({"error": f"Invalid format. Allowed: {', '.join(allowed_extensions)}"}), 400
|
|
701
|
+
|
|
702
|
+
safe_name = "".join(c for c in Path(file.filename).stem if c.isalnum() or c in " _-")
|
|
703
|
+
safe_name = safe_name[:50] + ext
|
|
704
|
+
|
|
705
|
+
save_path = MUSIC_DIR / safe_name
|
|
706
|
+
file.save(save_path)
|
|
707
|
+
|
|
708
|
+
return jsonify({
|
|
709
|
+
"status": "success",
|
|
710
|
+
"filename": safe_name,
|
|
711
|
+
"response": f"Track '{safe_name}' uploaded! Ready to spin.",
|
|
712
|
+
})
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# ---------------------------------------------------------------------------
|
|
716
|
+
# Playlist CRUD endpoints (P2-T4 requirement)
|
|
717
|
+
# ---------------------------------------------------------------------------
|
|
718
|
+
|
|
719
|
+
@music_bp.route("/api/music/playlists", methods=["GET"])
|
|
720
|
+
def list_playlists():
|
|
721
|
+
"""List all available playlists with track counts and total sizes."""
|
|
722
|
+
playlists = []
|
|
723
|
+
for name, music_dir in (("library", MUSIC_DIR), ("generated", GENERATED_MUSIC_DIR)):
|
|
724
|
+
music_extensions = {".mp3", ".wav", ".ogg", ".m4a", ".webm"}
|
|
725
|
+
tracks = [f for f in music_dir.iterdir() if f.is_file() and f.suffix.lower() in music_extensions]
|
|
726
|
+
playlists.append({
|
|
727
|
+
"name": name,
|
|
728
|
+
"track_count": len(tracks),
|
|
729
|
+
"total_size_bytes": sum(f.stat().st_size for f in tracks),
|
|
730
|
+
"active": current_music_state.get("current_playlist") == name,
|
|
731
|
+
})
|
|
732
|
+
# Spotify is a virtual playlist — no local files
|
|
733
|
+
playlists.append({
|
|
734
|
+
"name": "spotify",
|
|
735
|
+
"track_count": None,
|
|
736
|
+
"total_size_bytes": None,
|
|
737
|
+
"active": current_music_state.get("current_playlist") == "spotify",
|
|
738
|
+
"source": "spotify",
|
|
739
|
+
"description": "Stream any song, album, or playlist from Spotify",
|
|
740
|
+
})
|
|
741
|
+
return jsonify({"playlists": playlists})
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
@music_bp.route("/api/music/track/<playlist>/<filename>", methods=["DELETE"])
|
|
745
|
+
def delete_track(playlist, filename):
|
|
746
|
+
"""Delete a track from a playlist."""
|
|
747
|
+
if playlist == "generated":
|
|
748
|
+
music_dir = GENERATED_MUSIC_DIR
|
|
749
|
+
load_meta = load_generated_music_metadata
|
|
750
|
+
save_meta = save_generated_music_metadata
|
|
751
|
+
elif playlist == "library":
|
|
752
|
+
music_dir = MUSIC_DIR
|
|
753
|
+
load_meta = load_music_metadata
|
|
754
|
+
save_meta = save_music_metadata
|
|
755
|
+
else:
|
|
756
|
+
return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
|
|
757
|
+
|
|
758
|
+
safe_filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
|
|
759
|
+
track_path = music_dir / safe_filename
|
|
760
|
+
|
|
761
|
+
if not track_path.exists():
|
|
762
|
+
return jsonify({"error": "Track not found"}), 404
|
|
763
|
+
|
|
764
|
+
track_path.unlink()
|
|
765
|
+
|
|
766
|
+
# Remove from metadata if present
|
|
767
|
+
metadata = load_meta()
|
|
768
|
+
if safe_filename in metadata:
|
|
769
|
+
del metadata[safe_filename]
|
|
770
|
+
save_meta(metadata)
|
|
771
|
+
|
|
772
|
+
# Clear from state if this was the active/reserved track
|
|
773
|
+
current = current_music_state.get("current_track") or {}
|
|
774
|
+
if current.get("filename") == safe_filename:
|
|
775
|
+
current_music_state["current_track"] = None
|
|
776
|
+
current_music_state["playing"] = False
|
|
777
|
+
reserved = current_music_state.get("reserved_track") or {}
|
|
778
|
+
if reserved.get("filename") == safe_filename:
|
|
779
|
+
clear_reservation()
|
|
780
|
+
|
|
781
|
+
return jsonify({"status": "deleted", "filename": safe_filename, "playlist": playlist})
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@music_bp.route("/api/music/playlist/<playlist>/order", methods=["GET", "POST"])
|
|
785
|
+
def playlist_order(playlist):
|
|
786
|
+
"""
|
|
787
|
+
GET : Return the saved track order for the playlist (list of filenames).
|
|
788
|
+
POST: Save a new track order. Body: {"order": ["file1.mp3", "file2.mp3", ...]}
|
|
789
|
+
"""
|
|
790
|
+
if playlist not in ("library", "generated"):
|
|
791
|
+
return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
|
|
792
|
+
|
|
793
|
+
if request.method == "GET":
|
|
794
|
+
return jsonify({"playlist": playlist, "order": load_playlist_order(playlist)})
|
|
795
|
+
|
|
796
|
+
data = request.get_json()
|
|
797
|
+
if not data or not isinstance(data.get("order"), list):
|
|
798
|
+
return jsonify({"error": "Body must be JSON with 'order' array of filenames"}), 400
|
|
799
|
+
|
|
800
|
+
order = [str(f) for f in data["order"]]
|
|
801
|
+
save_playlist_order(playlist, order)
|
|
802
|
+
return jsonify({"status": "saved", "playlist": playlist, "order": order})
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
@music_bp.route("/api/music/track/<playlist>/<filename>/metadata", methods=["PUT"])
|
|
806
|
+
def update_track_metadata(playlist, filename):
|
|
807
|
+
"""Update metadata for a track (title, artist, description, etc.)."""
|
|
808
|
+
if playlist == "generated":
|
|
809
|
+
load_meta = load_generated_music_metadata
|
|
810
|
+
save_meta = save_generated_music_metadata
|
|
811
|
+
music_dir = GENERATED_MUSIC_DIR
|
|
812
|
+
elif playlist == "library":
|
|
813
|
+
load_meta = load_music_metadata
|
|
814
|
+
save_meta = save_music_metadata
|
|
815
|
+
music_dir = MUSIC_DIR
|
|
816
|
+
else:
|
|
817
|
+
return jsonify({"error": f"Unknown playlist '{playlist}'"}), 400
|
|
818
|
+
|
|
819
|
+
safe_filename = "".join(c for c in filename if c.isalnum() or c in "._- ")
|
|
820
|
+
track_path = music_dir / safe_filename
|
|
821
|
+
if not track_path.exists():
|
|
822
|
+
return jsonify({"error": "Track not found"}), 404
|
|
823
|
+
|
|
824
|
+
data = request.get_json()
|
|
825
|
+
if not data:
|
|
826
|
+
return jsonify({"error": "No JSON body provided"}), 400
|
|
827
|
+
|
|
828
|
+
allowed_fields = {"title", "artist", "description", "duration_seconds", "phone_number",
|
|
829
|
+
"ad_copy", "fun_facts", "genre", "energy", "dj_intro_hints"}
|
|
830
|
+
metadata = load_meta()
|
|
831
|
+
entry = metadata.get(safe_filename, {})
|
|
832
|
+
for field in allowed_fields:
|
|
833
|
+
if field in data:
|
|
834
|
+
entry[field] = data[field]
|
|
835
|
+
metadata[safe_filename] = entry
|
|
836
|
+
save_meta(metadata)
|
|
837
|
+
|
|
838
|
+
return jsonify({"status": "updated", "filename": safe_filename, "playlist": playlist, "metadata": entry})
|