coder-music-cli 0.1.0__py3-none-any.whl
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.
- coder_music_cli-0.1.0.dist-info/METADATA +167 -0
- coder_music_cli-0.1.0.dist-info/RECORD +23 -0
- coder_music_cli-0.1.0.dist-info/WHEEL +5 -0
- coder_music_cli-0.1.0.dist-info/entry_points.txt +2 -0
- coder_music_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- coder_music_cli-0.1.0.dist-info/top_level.txt +1 -0
- music_cli/__init__.py +3 -0
- music_cli/__main__.py +6 -0
- music_cli/cli.py +378 -0
- music_cli/client.py +161 -0
- music_cli/config.py +181 -0
- music_cli/context/__init__.py +6 -0
- music_cli/context/mood.py +134 -0
- music_cli/context/temporal.py +171 -0
- music_cli/daemon.py +374 -0
- music_cli/history.py +176 -0
- music_cli/player/__init__.py +6 -0
- music_cli/player/base.py +108 -0
- music_cli/player/ffplay.py +179 -0
- music_cli/sources/__init__.py +6 -0
- music_cli/sources/ai_generator.py +219 -0
- music_cli/sources/local.py +75 -0
- music_cli/sources/radio.py +90 -0
music_cli/daemon.py
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Background daemon for music-cli."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
import signal
|
|
8
|
+
|
|
9
|
+
from .config import get_config
|
|
10
|
+
from .context.mood import Mood, MoodContext
|
|
11
|
+
from .context.temporal import TemporalContext
|
|
12
|
+
from .history import get_history
|
|
13
|
+
from .player.base import TrackInfo
|
|
14
|
+
from .player.ffplay import FFplayPlayer
|
|
15
|
+
from .sources.local import LocalSource
|
|
16
|
+
from .sources.radio import RadioSource
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class MusicDaemon:
|
|
22
|
+
"""Background daemon that handles music playback."""
|
|
23
|
+
|
|
24
|
+
def __init__(self):
|
|
25
|
+
self.config = get_config()
|
|
26
|
+
self.player = FFplayPlayer()
|
|
27
|
+
self.local_source = LocalSource()
|
|
28
|
+
self.radio_source = RadioSource()
|
|
29
|
+
self.history = get_history()
|
|
30
|
+
self.temporal = TemporalContext()
|
|
31
|
+
|
|
32
|
+
self._server: asyncio.Server | None = None
|
|
33
|
+
self._running = False
|
|
34
|
+
self._current_mood: Mood | None = None
|
|
35
|
+
self._auto_play = False # For infinite/context-aware mode
|
|
36
|
+
|
|
37
|
+
async def start(self) -> None:
|
|
38
|
+
"""Start the daemon server."""
|
|
39
|
+
socket_path = self.config.socket_path
|
|
40
|
+
|
|
41
|
+
# Clean up stale socket
|
|
42
|
+
if socket_path.exists():
|
|
43
|
+
socket_path.unlink()
|
|
44
|
+
|
|
45
|
+
self._running = True
|
|
46
|
+
|
|
47
|
+
# Set up signal handlers
|
|
48
|
+
loop = asyncio.get_event_loop()
|
|
49
|
+
for sig in (signal.SIGTERM, signal.SIGINT):
|
|
50
|
+
loop.add_signal_handler(sig, lambda: asyncio.create_task(self.stop()))
|
|
51
|
+
|
|
52
|
+
# Start Unix socket server
|
|
53
|
+
self._server = await asyncio.start_unix_server(
|
|
54
|
+
self._handle_client,
|
|
55
|
+
path=str(socket_path),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Set socket permissions
|
|
59
|
+
socket_path.chmod(0o600)
|
|
60
|
+
|
|
61
|
+
# Write PID file
|
|
62
|
+
self.config.pid_file.write_text(str(os.getpid()))
|
|
63
|
+
|
|
64
|
+
logger.info(f"Daemon started, listening on {socket_path}")
|
|
65
|
+
|
|
66
|
+
async with self._server:
|
|
67
|
+
await self._server.serve_forever()
|
|
68
|
+
|
|
69
|
+
async def stop(self) -> None:
|
|
70
|
+
"""Stop the daemon."""
|
|
71
|
+
logger.info("Stopping daemon...")
|
|
72
|
+
self._running = False
|
|
73
|
+
|
|
74
|
+
await self.player.stop()
|
|
75
|
+
|
|
76
|
+
if self._server:
|
|
77
|
+
self._server.close()
|
|
78
|
+
await self._server.wait_closed()
|
|
79
|
+
|
|
80
|
+
# Clean up files
|
|
81
|
+
if self.config.socket_path.exists():
|
|
82
|
+
self.config.socket_path.unlink()
|
|
83
|
+
if self.config.pid_file.exists():
|
|
84
|
+
self.config.pid_file.unlink()
|
|
85
|
+
|
|
86
|
+
logger.info("Daemon stopped")
|
|
87
|
+
|
|
88
|
+
async def _handle_client(
|
|
89
|
+
self,
|
|
90
|
+
reader: asyncio.StreamReader,
|
|
91
|
+
writer: asyncio.StreamWriter,
|
|
92
|
+
) -> None:
|
|
93
|
+
"""Handle a client connection."""
|
|
94
|
+
try:
|
|
95
|
+
data = await reader.read(4096)
|
|
96
|
+
if not data:
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
request = json.loads(data.decode())
|
|
101
|
+
except json.JSONDecodeError:
|
|
102
|
+
response = {"error": "Invalid JSON"}
|
|
103
|
+
writer.write(json.dumps(response).encode())
|
|
104
|
+
await writer.drain()
|
|
105
|
+
return
|
|
106
|
+
|
|
107
|
+
command = request.get("command", "")
|
|
108
|
+
args = request.get("args", {})
|
|
109
|
+
|
|
110
|
+
response = await self._process_command(command, args)
|
|
111
|
+
|
|
112
|
+
writer.write(json.dumps(response).encode())
|
|
113
|
+
await writer.drain()
|
|
114
|
+
|
|
115
|
+
except Exception as e:
|
|
116
|
+
logger.error(f"Error handling client: {e}")
|
|
117
|
+
finally:
|
|
118
|
+
writer.close()
|
|
119
|
+
await writer.wait_closed()
|
|
120
|
+
|
|
121
|
+
async def _process_command(self, command: str, args: dict) -> dict:
|
|
122
|
+
"""Process a command and return response."""
|
|
123
|
+
handlers = {
|
|
124
|
+
"play": self._cmd_play,
|
|
125
|
+
"stop": self._cmd_stop,
|
|
126
|
+
"pause": self._cmd_pause,
|
|
127
|
+
"resume": self._cmd_resume,
|
|
128
|
+
"status": self._cmd_status,
|
|
129
|
+
"next": self._cmd_next,
|
|
130
|
+
"volume": self._cmd_volume,
|
|
131
|
+
"list_radios": self._cmd_list_radios,
|
|
132
|
+
"list_history": self._cmd_list_history,
|
|
133
|
+
"ping": self._cmd_ping,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
handler = handlers.get(command)
|
|
137
|
+
if handler:
|
|
138
|
+
try:
|
|
139
|
+
return await handler(args)
|
|
140
|
+
except Exception as e:
|
|
141
|
+
logger.error(f"Error processing {command}: {e}")
|
|
142
|
+
return {"error": str(e)}
|
|
143
|
+
else:
|
|
144
|
+
return {"error": f"Unknown command: {command}"}
|
|
145
|
+
|
|
146
|
+
async def _cmd_ping(self, args: dict) -> dict:
|
|
147
|
+
"""Health check."""
|
|
148
|
+
return {"status": "ok", "message": "pong"}
|
|
149
|
+
|
|
150
|
+
async def _cmd_play(self, args: dict) -> dict:
|
|
151
|
+
"""Play music based on arguments."""
|
|
152
|
+
mode = args.get("mode", "radio")
|
|
153
|
+
source = args.get("source")
|
|
154
|
+
mood = args.get("mood")
|
|
155
|
+
self._auto_play = args.get("auto", False)
|
|
156
|
+
|
|
157
|
+
track: TrackInfo | None = None
|
|
158
|
+
|
|
159
|
+
if mood:
|
|
160
|
+
self._current_mood = MoodContext.parse_mood(mood)
|
|
161
|
+
|
|
162
|
+
if mode == "local":
|
|
163
|
+
if source:
|
|
164
|
+
track = self.local_source.get_track(source)
|
|
165
|
+
else:
|
|
166
|
+
track = self.local_source.get_random_track()
|
|
167
|
+
|
|
168
|
+
elif mode == "radio":
|
|
169
|
+
if source:
|
|
170
|
+
# Try as station name first
|
|
171
|
+
track = self.radio_source.get_station_by_name(source)
|
|
172
|
+
if not track:
|
|
173
|
+
# Try as URL
|
|
174
|
+
track = self.radio_source.get_track(source)
|
|
175
|
+
elif mood and self._current_mood:
|
|
176
|
+
track = self.radio_source.get_mood_station(self._current_mood.value)
|
|
177
|
+
else:
|
|
178
|
+
# Use temporal context
|
|
179
|
+
time_period = self.temporal.get_time_period()
|
|
180
|
+
track = self.radio_source.get_time_station(time_period.value)
|
|
181
|
+
if not track:
|
|
182
|
+
track = self.radio_source.get_random_station()
|
|
183
|
+
|
|
184
|
+
elif mode == "ai":
|
|
185
|
+
# Try to use AI generation
|
|
186
|
+
try:
|
|
187
|
+
from .sources.ai_generator import AIGenerator, is_ai_available
|
|
188
|
+
|
|
189
|
+
if not is_ai_available():
|
|
190
|
+
return {
|
|
191
|
+
"error": "AI generation not available. Install with: pip install 'music-cli[ai]'"
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
generator = AIGenerator()
|
|
195
|
+
|
|
196
|
+
# Build prompt
|
|
197
|
+
temporal_prompt = self.temporal.get_music_prompt()
|
|
198
|
+
mood_prompt = None
|
|
199
|
+
if self._current_mood:
|
|
200
|
+
mood_prompt = MoodContext.get_prompt(self._current_mood)
|
|
201
|
+
|
|
202
|
+
duration = args.get("duration", 30)
|
|
203
|
+
track = generator.generate_for_context(mood_prompt, temporal_prompt, duration)
|
|
204
|
+
|
|
205
|
+
except ImportError:
|
|
206
|
+
return {
|
|
207
|
+
"error": "AI generation not available. Install with: pip install 'music-cli[ai]'"
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
elif mode == "context":
|
|
211
|
+
# Context-aware mode: use radio with mood/time awareness
|
|
212
|
+
if self._current_mood:
|
|
213
|
+
track = self.radio_source.get_mood_station(self._current_mood.value)
|
|
214
|
+
else:
|
|
215
|
+
time_period = self.temporal.get_time_period()
|
|
216
|
+
track = self.radio_source.get_time_station(time_period.value)
|
|
217
|
+
|
|
218
|
+
if not track:
|
|
219
|
+
track = self.radio_source.get_random_station()
|
|
220
|
+
|
|
221
|
+
elif mode == "history":
|
|
222
|
+
# Play from history
|
|
223
|
+
index = args.get("index", 1)
|
|
224
|
+
entry = self.history.get_by_index(index)
|
|
225
|
+
if entry:
|
|
226
|
+
if entry.source_type == "local":
|
|
227
|
+
track = self.local_source.get_track(entry.source)
|
|
228
|
+
else:
|
|
229
|
+
track = self.radio_source.get_track(entry.source, entry.title)
|
|
230
|
+
|
|
231
|
+
if not track:
|
|
232
|
+
return {"error": "Could not find track to play"}
|
|
233
|
+
|
|
234
|
+
# Set up callback for auto-play
|
|
235
|
+
if self._auto_play and track.source_type == "local":
|
|
236
|
+
self.player.set_on_track_end(self._on_track_end)
|
|
237
|
+
else:
|
|
238
|
+
self.player.set_on_track_end(None)
|
|
239
|
+
|
|
240
|
+
success = await self.player.play(track)
|
|
241
|
+
|
|
242
|
+
if success:
|
|
243
|
+
# Log to history
|
|
244
|
+
self.history.log(
|
|
245
|
+
source=track.source,
|
|
246
|
+
source_type=track.source_type,
|
|
247
|
+
title=track.title,
|
|
248
|
+
artist=track.artist,
|
|
249
|
+
mood=self._current_mood.value if self._current_mood else None,
|
|
250
|
+
context=self.temporal.get_time_period().value,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
"status": "playing",
|
|
255
|
+
"track": track.to_dict(),
|
|
256
|
+
}
|
|
257
|
+
else:
|
|
258
|
+
return {"error": "Failed to start playback"}
|
|
259
|
+
|
|
260
|
+
def _on_track_end(self) -> None:
|
|
261
|
+
"""Called when a track ends in auto-play mode."""
|
|
262
|
+
if self._auto_play:
|
|
263
|
+
asyncio.create_task(self._play_next())
|
|
264
|
+
|
|
265
|
+
async def _play_next(self) -> None:
|
|
266
|
+
"""Play the next track in auto-play mode."""
|
|
267
|
+
track = self.local_source.get_random_track()
|
|
268
|
+
if track:
|
|
269
|
+
await self.player.play(track)
|
|
270
|
+
self.history.log(
|
|
271
|
+
source=track.source,
|
|
272
|
+
source_type=track.source_type,
|
|
273
|
+
title=track.title,
|
|
274
|
+
artist=track.artist,
|
|
275
|
+
mood=self._current_mood.value if self._current_mood else None,
|
|
276
|
+
context=self.temporal.get_time_period().value,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def _cmd_stop(self, args: dict) -> dict:
|
|
280
|
+
"""Stop playback."""
|
|
281
|
+
self._auto_play = False
|
|
282
|
+
await self.player.stop()
|
|
283
|
+
return {"status": "stopped"}
|
|
284
|
+
|
|
285
|
+
async def _cmd_pause(self, args: dict) -> dict:
|
|
286
|
+
"""Pause playback."""
|
|
287
|
+
await self.player.pause()
|
|
288
|
+
return {"status": "paused"}
|
|
289
|
+
|
|
290
|
+
async def _cmd_resume(self, args: dict) -> dict:
|
|
291
|
+
"""Resume playback."""
|
|
292
|
+
await self.player.resume()
|
|
293
|
+
return {"status": "playing"}
|
|
294
|
+
|
|
295
|
+
async def _cmd_status(self, args: dict) -> dict:
|
|
296
|
+
"""Get current status."""
|
|
297
|
+
status = self.player.get_status()
|
|
298
|
+
status["auto_play"] = self._auto_play
|
|
299
|
+
status["mood"] = self._current_mood.value if self._current_mood else None
|
|
300
|
+
status["context"] = self.temporal.get_info().to_dict()
|
|
301
|
+
return status
|
|
302
|
+
|
|
303
|
+
async def _cmd_next(self, args: dict) -> dict:
|
|
304
|
+
"""Skip to next track (for auto-play mode)."""
|
|
305
|
+
if self._auto_play:
|
|
306
|
+
await self._play_next()
|
|
307
|
+
return {"status": "playing_next"}
|
|
308
|
+
else:
|
|
309
|
+
return {"error": "Auto-play not enabled"}
|
|
310
|
+
|
|
311
|
+
async def _cmd_volume(self, args: dict) -> dict:
|
|
312
|
+
"""Set volume."""
|
|
313
|
+
volume = args.get("level")
|
|
314
|
+
if volume is None:
|
|
315
|
+
return {"volume": self.player.volume}
|
|
316
|
+
await self.player.set_volume(int(volume))
|
|
317
|
+
return {"volume": self.player.volume}
|
|
318
|
+
|
|
319
|
+
async def _cmd_list_radios(self, args: dict) -> dict:
|
|
320
|
+
"""List available radio stations."""
|
|
321
|
+
return {"stations": self.radio_source.list_stations()}
|
|
322
|
+
|
|
323
|
+
async def _cmd_list_history(self, args: dict) -> dict:
|
|
324
|
+
"""List playback history."""
|
|
325
|
+
limit = args.get("limit", 20)
|
|
326
|
+
entries = self.history.get_all(limit=limit)
|
|
327
|
+
return {"history": [{"index": i + 1, **e.to_dict()} for i, e in enumerate(entries)]}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def run_daemon() -> None:
|
|
331
|
+
"""Run the daemon (entry point)."""
|
|
332
|
+
logging.basicConfig(
|
|
333
|
+
level=logging.INFO,
|
|
334
|
+
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
daemon = MusicDaemon()
|
|
338
|
+
asyncio.run(daemon.start())
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def get_daemon_pid() -> int | None:
|
|
342
|
+
"""Get the PID of the running daemon.
|
|
343
|
+
|
|
344
|
+
Returns the PID if daemon is running, None otherwise.
|
|
345
|
+
Also cleans up stale PID/socket files if the daemon is not running.
|
|
346
|
+
"""
|
|
347
|
+
config = get_config()
|
|
348
|
+
|
|
349
|
+
if not config.pid_file.exists():
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
pid = int(config.pid_file.read_text().strip())
|
|
354
|
+
os.kill(pid, 0) # Check if running
|
|
355
|
+
return pid
|
|
356
|
+
except (ValueError, ProcessLookupError, PermissionError):
|
|
357
|
+
# PID file is stale, clean up
|
|
358
|
+
try:
|
|
359
|
+
if config.pid_file.exists():
|
|
360
|
+
config.pid_file.unlink()
|
|
361
|
+
if config.socket_path.exists():
|
|
362
|
+
config.socket_path.unlink()
|
|
363
|
+
except OSError:
|
|
364
|
+
pass # Best effort cleanup
|
|
365
|
+
return None
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def is_daemon_running() -> bool:
|
|
369
|
+
"""Check if daemon is already running."""
|
|
370
|
+
return get_daemon_pid() is not None
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
run_daemon()
|
music_cli/history.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""History logging and management for music-cli."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .config import get_config
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class HistoryEntry:
|
|
13
|
+
"""A single history entry."""
|
|
14
|
+
|
|
15
|
+
timestamp: str
|
|
16
|
+
source: str
|
|
17
|
+
source_type: str
|
|
18
|
+
title: str | None = None
|
|
19
|
+
artist: str | None = None
|
|
20
|
+
mood: str | None = None
|
|
21
|
+
context: str | None = None # e.g., "morning", "focus", etc.
|
|
22
|
+
|
|
23
|
+
def to_dict(self) -> dict:
|
|
24
|
+
"""Convert to dictionary for JSON serialization."""
|
|
25
|
+
return {
|
|
26
|
+
"timestamp": self.timestamp,
|
|
27
|
+
"source": self.source,
|
|
28
|
+
"source_type": self.source_type,
|
|
29
|
+
"title": self.title,
|
|
30
|
+
"artist": self.artist,
|
|
31
|
+
"mood": self.mood,
|
|
32
|
+
"context": self.context,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict) -> "HistoryEntry":
|
|
37
|
+
"""Create from dictionary."""
|
|
38
|
+
return cls(
|
|
39
|
+
timestamp=data.get("timestamp", ""),
|
|
40
|
+
source=data.get("source", ""),
|
|
41
|
+
source_type=data.get("source_type", "unknown"),
|
|
42
|
+
title=data.get("title"),
|
|
43
|
+
artist=data.get("artist"),
|
|
44
|
+
mood=data.get("mood"),
|
|
45
|
+
context=data.get("context"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def display_str(self) -> str:
|
|
49
|
+
"""Get a display-friendly string."""
|
|
50
|
+
parts = [self.timestamp]
|
|
51
|
+
if self.title:
|
|
52
|
+
parts.append(self.title)
|
|
53
|
+
elif self.source:
|
|
54
|
+
# Use filename for local files
|
|
55
|
+
if self.source_type == "local":
|
|
56
|
+
parts.append(Path(self.source).name)
|
|
57
|
+
else:
|
|
58
|
+
parts.append(self.source[:50] + "..." if len(self.source) > 50 else self.source)
|
|
59
|
+
if self.artist:
|
|
60
|
+
parts.append(f"by {self.artist}")
|
|
61
|
+
parts.append(f"[{self.source_type}]")
|
|
62
|
+
return " | ".join(parts)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class History:
|
|
66
|
+
"""Manages playback history."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, history_file: Path | None = None):
|
|
69
|
+
"""Initialize history with optional custom file path."""
|
|
70
|
+
if history_file is None:
|
|
71
|
+
history_file = get_config().history_file
|
|
72
|
+
self.history_file = history_file
|
|
73
|
+
|
|
74
|
+
def log(
|
|
75
|
+
self,
|
|
76
|
+
source: str,
|
|
77
|
+
source_type: str,
|
|
78
|
+
title: str | None = None,
|
|
79
|
+
artist: str | None = None,
|
|
80
|
+
mood: str | None = None,
|
|
81
|
+
context: str | None = None,
|
|
82
|
+
) -> HistoryEntry:
|
|
83
|
+
"""Log a new history entry."""
|
|
84
|
+
entry = HistoryEntry(
|
|
85
|
+
timestamp=datetime.now().isoformat(),
|
|
86
|
+
source=source,
|
|
87
|
+
source_type=source_type,
|
|
88
|
+
title=title,
|
|
89
|
+
artist=artist,
|
|
90
|
+
mood=mood,
|
|
91
|
+
context=context,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
with self.history_file.open("a") as f:
|
|
95
|
+
f.write(json.dumps(entry.to_dict()) + "\n")
|
|
96
|
+
|
|
97
|
+
return entry
|
|
98
|
+
|
|
99
|
+
def get_all(self, limit: int | None = None) -> list[HistoryEntry]:
|
|
100
|
+
"""Get all history entries, optionally limited.
|
|
101
|
+
|
|
102
|
+
Returns entries in reverse chronological order (newest first).
|
|
103
|
+
"""
|
|
104
|
+
entries: list[HistoryEntry] = []
|
|
105
|
+
|
|
106
|
+
if not self.history_file.exists():
|
|
107
|
+
return entries
|
|
108
|
+
|
|
109
|
+
for line in self.history_file.read_text().splitlines():
|
|
110
|
+
line = line.strip()
|
|
111
|
+
if not line:
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
data = json.loads(line)
|
|
115
|
+
entries.append(HistoryEntry.from_dict(data))
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
# Reverse to get newest first
|
|
120
|
+
entries.reverse()
|
|
121
|
+
|
|
122
|
+
if limit:
|
|
123
|
+
entries = entries[:limit]
|
|
124
|
+
|
|
125
|
+
return entries
|
|
126
|
+
|
|
127
|
+
def get_by_index(self, index: int) -> HistoryEntry | None:
|
|
128
|
+
"""Get a history entry by its index (1-based, newest first)."""
|
|
129
|
+
entries = self.get_all()
|
|
130
|
+
if 1 <= index <= len(entries):
|
|
131
|
+
return entries[index - 1]
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
def search(self, query: str, limit: int = 20) -> list[HistoryEntry]:
|
|
135
|
+
"""Search history entries by title, artist, or source."""
|
|
136
|
+
query = query.lower()
|
|
137
|
+
results = []
|
|
138
|
+
|
|
139
|
+
for entry in self.get_all():
|
|
140
|
+
if (
|
|
141
|
+
(entry.title and query in entry.title.lower())
|
|
142
|
+
or (entry.artist and query in entry.artist.lower())
|
|
143
|
+
or (entry.source and query in entry.source.lower())
|
|
144
|
+
):
|
|
145
|
+
results.append(entry)
|
|
146
|
+
if len(results) >= limit:
|
|
147
|
+
break
|
|
148
|
+
|
|
149
|
+
return results
|
|
150
|
+
|
|
151
|
+
def clear(self) -> None:
|
|
152
|
+
"""Clear all history."""
|
|
153
|
+
if self.history_file.exists():
|
|
154
|
+
self.history_file.write_text("")
|
|
155
|
+
|
|
156
|
+
def get_recent_by_type(self, source_type: str, limit: int = 10) -> list[HistoryEntry]:
|
|
157
|
+
"""Get recent entries of a specific source type."""
|
|
158
|
+
results = []
|
|
159
|
+
for entry in self.get_all():
|
|
160
|
+
if entry.source_type == source_type:
|
|
161
|
+
results.append(entry)
|
|
162
|
+
if len(results) >= limit:
|
|
163
|
+
break
|
|
164
|
+
return results
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
# Global history instance
|
|
168
|
+
_history: History | None = None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_history() -> History:
|
|
172
|
+
"""Get the global history instance."""
|
|
173
|
+
global _history
|
|
174
|
+
if _history is None:
|
|
175
|
+
_history = History()
|
|
176
|
+
return _history
|
music_cli/player/base.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Abstract base player interface."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PlayerState(Enum):
|
|
10
|
+
"""Player state enumeration."""
|
|
11
|
+
|
|
12
|
+
STOPPED = "stopped"
|
|
13
|
+
PLAYING = "playing"
|
|
14
|
+
PAUSED = "paused"
|
|
15
|
+
LOADING = "loading"
|
|
16
|
+
ERROR = "error"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class TrackInfo:
|
|
21
|
+
"""Information about the currently playing track."""
|
|
22
|
+
|
|
23
|
+
source: str # File path or URL
|
|
24
|
+
source_type: str # "local", "radio", "ai"
|
|
25
|
+
title: str | None = None
|
|
26
|
+
artist: str | None = None
|
|
27
|
+
duration: float | None = None # Duration in seconds, None for streams
|
|
28
|
+
position: float = 0.0 # Current position in seconds
|
|
29
|
+
metadata: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
"""Convert to dictionary for JSON serialization."""
|
|
33
|
+
return {
|
|
34
|
+
"source": self.source,
|
|
35
|
+
"source_type": self.source_type,
|
|
36
|
+
"title": self.title,
|
|
37
|
+
"artist": self.artist,
|
|
38
|
+
"duration": self.duration,
|
|
39
|
+
"position": self.position,
|
|
40
|
+
"metadata": self.metadata,
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Player(ABC):
|
|
45
|
+
"""Abstract base class for audio players."""
|
|
46
|
+
|
|
47
|
+
def __init__(self):
|
|
48
|
+
self._state = PlayerState.STOPPED
|
|
49
|
+
self._current_track: TrackInfo | None = None
|
|
50
|
+
self._volume = 80
|
|
51
|
+
self._on_track_end: Callable[[], None] | None = None
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def state(self) -> PlayerState:
|
|
55
|
+
"""Get current player state."""
|
|
56
|
+
return self._state
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def current_track(self) -> TrackInfo | None:
|
|
60
|
+
"""Get info about the currently playing track."""
|
|
61
|
+
return self._current_track
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def volume(self) -> int:
|
|
65
|
+
"""Get current volume (0-100)."""
|
|
66
|
+
return self._volume
|
|
67
|
+
|
|
68
|
+
def set_on_track_end(self, callback: Callable[[], None] | None) -> None:
|
|
69
|
+
"""Set callback for when track ends."""
|
|
70
|
+
self._on_track_end = callback
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
async def play(self, track: TrackInfo) -> bool:
|
|
74
|
+
"""Start playing a track. Returns True if successful."""
|
|
75
|
+
pass
|
|
76
|
+
|
|
77
|
+
@abstractmethod
|
|
78
|
+
async def stop(self) -> None:
|
|
79
|
+
"""Stop playback."""
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
@abstractmethod
|
|
83
|
+
async def pause(self) -> None:
|
|
84
|
+
"""Pause playback."""
|
|
85
|
+
pass
|
|
86
|
+
|
|
87
|
+
@abstractmethod
|
|
88
|
+
async def resume(self) -> None:
|
|
89
|
+
"""Resume playback."""
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def set_volume(self, volume: int) -> None:
|
|
94
|
+
"""Set volume (0-100)."""
|
|
95
|
+
pass
|
|
96
|
+
|
|
97
|
+
@abstractmethod
|
|
98
|
+
async def get_position(self) -> float:
|
|
99
|
+
"""Get current playback position in seconds."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
def get_status(self) -> dict:
|
|
103
|
+
"""Get current player status as a dictionary."""
|
|
104
|
+
return {
|
|
105
|
+
"state": self._state.value,
|
|
106
|
+
"volume": self._volume,
|
|
107
|
+
"track": self._current_track.to_dict() if self._current_track else None,
|
|
108
|
+
}
|