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.
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
@@ -0,0 +1,6 @@
1
+ """Player module for music-cli."""
2
+
3
+ from .base import Player, PlayerState
4
+ from .ffplay import FFplayPlayer
5
+
6
+ __all__ = ["Player", "PlayerState", "FFplayPlayer"]
@@ -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
+ }