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/client.py ADDED
@@ -0,0 +1,161 @@
1
+ """Client for communicating with the music-cli daemon."""
2
+
3
+ import json
4
+ import logging
5
+ import socket
6
+ from typing import Any
7
+
8
+ from .config import get_config
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # Constants
13
+ SOCKET_BUFFER_SIZE = 4096
14
+ MAX_RESPONSE_SIZE = 10 * 1024 * 1024 # 10MB limit
15
+
16
+
17
+ class DaemonClient:
18
+ """Client for sending commands to the daemon."""
19
+
20
+ def __init__(self):
21
+ self.config = get_config()
22
+ self.socket_path = str(self.config.socket_path)
23
+
24
+ def send_command(self, command: str, args: dict | None = None) -> dict[str, Any]:
25
+ """Send a command to the daemon and get response.
26
+
27
+ Args:
28
+ command: Command name (play, stop, pause, resume, status, etc.)
29
+ args: Command arguments
30
+
31
+ Returns:
32
+ Response dictionary from daemon
33
+
34
+ Raises:
35
+ ConnectionError: If daemon is not running
36
+ """
37
+ if args is None:
38
+ args = {}
39
+
40
+ request = {
41
+ "command": command,
42
+ "args": args,
43
+ }
44
+
45
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
46
+ try:
47
+ sock.settimeout(10.0)
48
+ sock.connect(self.socket_path)
49
+
50
+ sock.sendall(json.dumps(request).encode())
51
+
52
+ # Receive response with size limit
53
+ response_data = b""
54
+ while len(response_data) < MAX_RESPONSE_SIZE:
55
+ chunk = sock.recv(SOCKET_BUFFER_SIZE)
56
+ if not chunk:
57
+ break
58
+ response_data += chunk
59
+
60
+ if len(response_data) >= MAX_RESPONSE_SIZE:
61
+ logger.warning("Response from daemon exceeded size limit")
62
+ return {"error": "Response too large from daemon"}
63
+
64
+ if response_data:
65
+ return json.loads(response_data.decode())
66
+ else:
67
+ return {"error": "Empty response from daemon"}
68
+
69
+ except FileNotFoundError as e:
70
+ raise ConnectionError("Daemon not running (socket not found)") from e
71
+ except ConnectionRefusedError as e:
72
+ raise ConnectionError("Daemon not running (connection refused)") from e
73
+ except socket.timeout as e:
74
+ raise ConnectionError("Daemon not responding (timeout)") from e
75
+ except json.JSONDecodeError as e:
76
+ logger.warning(f"Invalid JSON response from daemon: {e}")
77
+ return {"error": "Invalid response from daemon"}
78
+ finally:
79
+ sock.close()
80
+
81
+ def ping(self) -> bool:
82
+ """Check if daemon is running and responsive."""
83
+ try:
84
+ response = self.send_command("ping")
85
+ return response.get("status") == "ok"
86
+ except ConnectionError:
87
+ return False
88
+
89
+ def play(
90
+ self,
91
+ mode: str = "radio",
92
+ source: str | None = None,
93
+ mood: str | None = None,
94
+ auto: bool = False,
95
+ duration: int = 30,
96
+ index: int | None = None,
97
+ ) -> dict:
98
+ """Start playback.
99
+
100
+ Args:
101
+ mode: Playback mode (local, radio, ai, context, history)
102
+ source: Source path/URL/name
103
+ mood: Mood tag (happy, sad, focus, etc.)
104
+ auto: Enable auto-play for local files
105
+ duration: Duration for AI generation (seconds)
106
+ index: History entry index (for mode=history)
107
+ """
108
+ args = {"mode": mode, "auto": auto}
109
+ if source:
110
+ args["source"] = source
111
+ if mood:
112
+ args["mood"] = mood
113
+ if duration:
114
+ args["duration"] = duration
115
+ if index:
116
+ args["index"] = index
117
+ return self.send_command("play", args)
118
+
119
+ def stop(self) -> dict:
120
+ """Stop playback."""
121
+ return self.send_command("stop")
122
+
123
+ def pause(self) -> dict:
124
+ """Pause playback."""
125
+ return self.send_command("pause")
126
+
127
+ def resume(self) -> dict:
128
+ """Resume playback."""
129
+ return self.send_command("resume")
130
+
131
+ def status(self) -> dict:
132
+ """Get current status."""
133
+ return self.send_command("status")
134
+
135
+ def next_track(self) -> dict:
136
+ """Skip to next track (auto-play mode)."""
137
+ return self.send_command("next")
138
+
139
+ def set_volume(self, level: int) -> dict:
140
+ """Set volume level (0-100)."""
141
+ return self.send_command("volume", {"level": level})
142
+
143
+ def get_volume(self) -> int:
144
+ """Get current volume level."""
145
+ response = self.send_command("volume")
146
+ return response.get("volume", 80)
147
+
148
+ def list_radios(self) -> list[dict]:
149
+ """List available radio stations."""
150
+ response = self.send_command("list_radios")
151
+ return response.get("stations", [])
152
+
153
+ def list_history(self, limit: int = 20) -> list[dict]:
154
+ """List playback history."""
155
+ response = self.send_command("list_history", {"limit": limit})
156
+ return response.get("history", [])
157
+
158
+
159
+ def get_client() -> DaemonClient:
160
+ """Get a daemon client instance."""
161
+ return DaemonClient()
music_cli/config.py ADDED
@@ -0,0 +1,181 @@
1
+ """Configuration management for music-cli."""
2
+
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ if sys.version_info >= (3, 11):
9
+ import tomllib
10
+ else:
11
+ import tomli as tomllib
12
+
13
+ import tomli_w
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Config:
19
+ """Manages music-cli configuration files and directories."""
20
+
21
+ DEFAULT_CONFIG = {
22
+ "player": {
23
+ "backend": "ffplay",
24
+ "volume": 80,
25
+ },
26
+ "daemon": {
27
+ "socket_path": "~/.config/music-cli/music-cli.sock",
28
+ "pid_file": "~/.config/music-cli/music-cli.pid",
29
+ },
30
+ "context": {
31
+ "enabled": True,
32
+ "use_ai": False, # Requires optional AI dependencies
33
+ },
34
+ "mood_radio_map": {
35
+ "focus": "https://streams.ilovemusic.de/iloveradio17.mp3",
36
+ "happy": "https://streams.ilovemusic.de/iloveradio1.mp3",
37
+ "sad": "https://streams.ilovemusic.de/iloveradio5.mp3",
38
+ "excited": "https://streams.ilovemusic.de/iloveradio2.mp3",
39
+ },
40
+ "time_radio_map": {
41
+ "morning": "https://streams.ilovemusic.de/iloveradio17.mp3",
42
+ "afternoon": "https://streams.ilovemusic.de/iloveradio1.mp3",
43
+ "evening": "https://streams.ilovemusic.de/iloveradio5.mp3",
44
+ "night": "https://streams.ilovemusic.de/iloveradio9.mp3",
45
+ },
46
+ }
47
+
48
+ def __init__(self, config_dir: Path | None = None):
49
+ """Initialize config with optional custom directory."""
50
+ if config_dir is None:
51
+ config_dir = Path("~/.config/music-cli").expanduser()
52
+ self.config_dir = config_dir
53
+ self.config_file = self.config_dir / "config.toml"
54
+ self.radios_file = self.config_dir / "radios.txt"
55
+ self.history_file = self.config_dir / "history.jsonl"
56
+ self.socket_path = self.config_dir / "music-cli.sock"
57
+ self.pid_file = self.config_dir / "music-cli.pid"
58
+ self._config: dict[str, Any] = {}
59
+ self._ensure_config_dir()
60
+ self._load_config()
61
+
62
+ def _ensure_config_dir(self) -> None:
63
+ """Create config directory and default files if they don't exist."""
64
+ self.config_dir.mkdir(parents=True, exist_ok=True)
65
+
66
+ if not self.config_file.exists():
67
+ self._write_default_config()
68
+
69
+ if not self.radios_file.exists():
70
+ self._write_default_radios()
71
+
72
+ if not self.history_file.exists():
73
+ self.history_file.touch()
74
+
75
+ def _write_default_config(self) -> None:
76
+ """Write default configuration file."""
77
+ with self.config_file.open("wb") as f:
78
+ tomli_w.dump(self.DEFAULT_CONFIG, f)
79
+
80
+ def _write_default_radios(self) -> None:
81
+ """Write default radio stations file."""
82
+ default_radios = """# Radio stations for music-cli
83
+ # Add one URL per line. Lines starting with # are comments.
84
+ # Format: URL or "name|URL"
85
+
86
+ # Chill/Lo-fi
87
+ ChillHop|https://streams.ilovemusic.de/iloveradio17.mp3
88
+
89
+ # Electronic
90
+ Deep House|https://streams.ilovemusic.de/iloveradio14.mp3
91
+
92
+ # Pop
93
+ Top Hits|https://streams.ilovemusic.de/iloveradio1.mp3
94
+
95
+ # Rock
96
+ Rock Radio|https://streams.ilovemusic.de/iloveradio3.mp3
97
+
98
+ # Classical
99
+ Classical|http://stream.srg-ssr.ch/m/rsc_de/mp3_128
100
+ """
101
+ self.radios_file.write_text(default_radios)
102
+
103
+ def _load_config(self) -> None:
104
+ """Load configuration from file."""
105
+ try:
106
+ with self.config_file.open("rb") as f:
107
+ self._config = tomllib.load(f)
108
+ except (OSError, tomllib.TOMLDecodeError) as e:
109
+ logger.warning(f"Failed to load config from {self.config_file}: {e}")
110
+ self._config = self.DEFAULT_CONFIG.copy()
111
+
112
+ def get(self, key: str, default: Any = None) -> Any:
113
+ """Get a config value using dot notation (e.g., 'player.volume')."""
114
+ keys = key.split(".")
115
+ value: Any = self._config
116
+ for k in keys:
117
+ if isinstance(value, dict):
118
+ value = value.get(k)
119
+ else:
120
+ return default
121
+ if value is None:
122
+ return default
123
+ return value
124
+
125
+ def set(self, key: str, value: Any) -> None:
126
+ """Set a config value using dot notation and save."""
127
+ keys = key.split(".")
128
+ config = self._config
129
+ for k in keys[:-1]:
130
+ if k not in config:
131
+ config[k] = {}
132
+ config = config[k]
133
+ config[keys[-1]] = value
134
+ self.save()
135
+
136
+ def save(self) -> None:
137
+ """Save current configuration to file."""
138
+ with self.config_file.open("wb") as f:
139
+ tomli_w.dump(self._config, f)
140
+
141
+ def get_radios(self) -> list[tuple[str, str]]:
142
+ """Load radio stations from radios.txt.
143
+
144
+ Returns list of (name, url) tuples.
145
+ """
146
+ radios: list[tuple[str, str]] = []
147
+ if not self.radios_file.exists():
148
+ return radios
149
+
150
+ for line in self.radios_file.read_text().splitlines():
151
+ line = line.strip()
152
+ if not line or line.startswith("#"):
153
+ continue
154
+ if "|" in line:
155
+ name, url = line.split("|", 1)
156
+ radios.append((name.strip(), url.strip()))
157
+ else:
158
+ radios.append((line, line))
159
+ return radios
160
+
161
+ def get_mood_radio(self, mood: str) -> str | None:
162
+ """Get radio URL for a specific mood."""
163
+ mood_map = self.get("mood_radio_map", {})
164
+ return mood_map.get(mood.lower())
165
+
166
+ def get_time_radio(self, time_period: str) -> str | None:
167
+ """Get radio URL for a time period (morning/afternoon/evening/night)."""
168
+ time_map = self.get("time_radio_map", {})
169
+ return time_map.get(time_period.lower())
170
+
171
+
172
+ # Global config instance
173
+ _config: Config | None = None
174
+
175
+
176
+ def get_config() -> Config:
177
+ """Get the global config instance."""
178
+ global _config
179
+ if _config is None:
180
+ _config = Config()
181
+ return _config
@@ -0,0 +1,6 @@
1
+ """Context-aware music selection for music-cli."""
2
+
3
+ from .mood import MoodContext
4
+ from .temporal import TemporalContext
5
+
6
+ __all__ = ["TemporalContext", "MoodContext"]
@@ -0,0 +1,134 @@
1
+ """Mood-based music selection."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+
6
+
7
+ class Mood(Enum):
8
+ """Supported mood types."""
9
+
10
+ HAPPY = "happy"
11
+ SAD = "sad"
12
+ EXCITED = "excited"
13
+ FOCUS = "focus"
14
+ RELAXED = "relaxed"
15
+ ENERGETIC = "energetic"
16
+ MELANCHOLIC = "melancholic"
17
+ PEACEFUL = "peaceful"
18
+
19
+
20
+ @dataclass
21
+ class MoodInfo:
22
+ """Mood context information."""
23
+
24
+ mood: Mood
25
+ intensity: float # 0.0 to 1.0
26
+
27
+ def to_dict(self) -> dict:
28
+ """Convert to dictionary."""
29
+ return {
30
+ "mood": self.mood.value,
31
+ "intensity": self.intensity,
32
+ }
33
+
34
+
35
+ class MoodContext:
36
+ """Manages mood-based music selection."""
37
+
38
+ # Music characteristics for each mood
39
+ MOOD_CHARACTERISTICS = {
40
+ Mood.HAPPY: {
41
+ "tempo": "upbeat",
42
+ "key": "major",
43
+ "energy": "high",
44
+ "genres": ["pop", "indie", "funk"],
45
+ },
46
+ Mood.SAD: {
47
+ "tempo": "slow",
48
+ "key": "minor",
49
+ "energy": "low",
50
+ "genres": ["acoustic", "ambient", "classical"],
51
+ },
52
+ Mood.EXCITED: {
53
+ "tempo": "fast",
54
+ "key": "major",
55
+ "energy": "very high",
56
+ "genres": ["electronic", "rock", "dance"],
57
+ },
58
+ Mood.FOCUS: {
59
+ "tempo": "medium",
60
+ "key": "any",
61
+ "energy": "medium",
62
+ "genres": ["lo-fi", "ambient", "classical", "instrumental"],
63
+ },
64
+ Mood.RELAXED: {
65
+ "tempo": "slow",
66
+ "key": "any",
67
+ "energy": "low",
68
+ "genres": ["chill", "ambient", "jazz"],
69
+ },
70
+ Mood.ENERGETIC: {
71
+ "tempo": "fast",
72
+ "key": "major",
73
+ "energy": "high",
74
+ "genres": ["electronic", "rock", "hip-hop"],
75
+ },
76
+ Mood.MELANCHOLIC: {
77
+ "tempo": "slow",
78
+ "key": "minor",
79
+ "energy": "low",
80
+ "genres": ["indie", "classical", "folk"],
81
+ },
82
+ Mood.PEACEFUL: {
83
+ "tempo": "very slow",
84
+ "key": "any",
85
+ "energy": "very low",
86
+ "genres": ["ambient", "nature", "meditation"],
87
+ },
88
+ }
89
+
90
+ # AI prompts for each mood
91
+ MOOD_PROMPTS = {
92
+ Mood.HAPPY: "cheerful, uplifting, feel-good music with major chords and upbeat rhythm",
93
+ Mood.SAD: "melancholic, emotional, slow tempo music with minor chords",
94
+ Mood.EXCITED: "high energy, fast tempo, exciting electronic or rock music",
95
+ Mood.FOCUS: "lo-fi hip hop, ambient, instrumental music for concentration",
96
+ Mood.RELAXED: "chill, relaxing, smooth jazz or ambient soundscapes",
97
+ Mood.ENERGETIC: "powerful, driving beats, high BPM electronic or rock",
98
+ Mood.MELANCHOLIC: "bittersweet, nostalgic, acoustic or indie music",
99
+ Mood.PEACEFUL: "serene, calm, nature sounds mixed with soft ambient music",
100
+ }
101
+
102
+ @classmethod
103
+ def parse_mood(cls, mood_str: str) -> Mood | None:
104
+ """Parse a mood string to Mood enum."""
105
+ mood_str = mood_str.lower().strip()
106
+ try:
107
+ return Mood(mood_str)
108
+ except ValueError:
109
+ # Try matching partial
110
+ for mood in Mood:
111
+ if mood.value.startswith(mood_str):
112
+ return mood
113
+ return None
114
+
115
+ @classmethod
116
+ def get_all_moods(cls) -> list[str]:
117
+ """Get list of all supported moods."""
118
+ return [m.value for m in Mood]
119
+
120
+ @classmethod
121
+ def get_characteristics(cls, mood: Mood) -> dict:
122
+ """Get music characteristics for a mood."""
123
+ return cls.MOOD_CHARACTERISTICS.get(mood, {})
124
+
125
+ @classmethod
126
+ def get_prompt(cls, mood: Mood) -> str:
127
+ """Get AI music generation prompt for a mood."""
128
+ return cls.MOOD_PROMPTS.get(mood, "background music")
129
+
130
+ @classmethod
131
+ def get_combined_prompt(cls, mood: Mood, temporal_prompt: str) -> str:
132
+ """Combine mood and temporal prompts for AI generation."""
133
+ mood_prompt = cls.get_prompt(mood)
134
+ return f"{mood_prompt}, {temporal_prompt}"
@@ -0,0 +1,171 @@
1
+ """Temporal context detection for music selection."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+
7
+
8
+ class TimePeriod(Enum):
9
+ """Time periods of the day."""
10
+
11
+ MORNING = "morning" # 6:00 - 12:00
12
+ AFTERNOON = "afternoon" # 12:00 - 17:00
13
+ EVENING = "evening" # 17:00 - 21:00
14
+ NIGHT = "night" # 21:00 - 6:00
15
+
16
+
17
+ class DayType(Enum):
18
+ """Types of days."""
19
+
20
+ WEEKDAY = "weekday"
21
+ WEEKEND = "weekend"
22
+
23
+
24
+ class Season(Enum):
25
+ """Seasons of the year."""
26
+
27
+ SPRING = "spring"
28
+ SUMMER = "summer"
29
+ AUTUMN = "autumn"
30
+ WINTER = "winter"
31
+
32
+
33
+ @dataclass
34
+ class TemporalInfo:
35
+ """Complete temporal context information."""
36
+
37
+ time_period: TimePeriod
38
+ day_type: DayType
39
+ season: Season
40
+ is_holiday: bool
41
+ hour: int
42
+ day_of_week: int # 0 = Monday, 6 = Sunday
43
+ month: int
44
+ day: int
45
+
46
+ def to_dict(self) -> dict:
47
+ """Convert to dictionary."""
48
+ return {
49
+ "time_period": self.time_period.value,
50
+ "day_type": self.day_type.value,
51
+ "season": self.season.value,
52
+ "is_holiday": self.is_holiday,
53
+ "hour": self.hour,
54
+ "day_of_week": self.day_of_week,
55
+ "month": self.month,
56
+ "day": self.day,
57
+ }
58
+
59
+
60
+ class TemporalContext:
61
+ """Detects temporal context for music selection."""
62
+
63
+ # Major US holidays (month, day)
64
+ HOLIDAYS = {
65
+ (1, 1): "New Year's Day",
66
+ (2, 14): "Valentine's Day",
67
+ (7, 4): "Independence Day",
68
+ (10, 31): "Halloween",
69
+ (12, 24): "Christmas Eve",
70
+ (12, 25): "Christmas",
71
+ (12, 31): "New Year's Eve",
72
+ }
73
+
74
+ def __init__(self, now: datetime | None = None):
75
+ """Initialize with optional custom datetime for testing."""
76
+ self._now = now
77
+
78
+ @property
79
+ def now(self) -> datetime:
80
+ """Get current datetime."""
81
+ return self._now or datetime.now()
82
+
83
+ def get_time_period(self) -> TimePeriod:
84
+ """Get current time period of the day."""
85
+ hour = self.now.hour
86
+
87
+ if 6 <= hour < 12:
88
+ return TimePeriod.MORNING
89
+ elif 12 <= hour < 17:
90
+ return TimePeriod.AFTERNOON
91
+ elif 17 <= hour < 21:
92
+ return TimePeriod.EVENING
93
+ else:
94
+ return TimePeriod.NIGHT
95
+
96
+ def get_day_type(self) -> DayType:
97
+ """Get current day type (weekday/weekend)."""
98
+ if self.now.weekday() >= 5: # Saturday = 5, Sunday = 6
99
+ return DayType.WEEKEND
100
+ return DayType.WEEKDAY
101
+
102
+ def get_season(self) -> Season:
103
+ """Get current season (Northern Hemisphere)."""
104
+ month = self.now.month
105
+
106
+ if month in (3, 4, 5):
107
+ return Season.SPRING
108
+ elif month in (6, 7, 8):
109
+ return Season.SUMMER
110
+ elif month in (9, 10, 11):
111
+ return Season.AUTUMN
112
+ else:
113
+ return Season.WINTER
114
+
115
+ def is_holiday(self) -> bool:
116
+ """Check if today is a holiday."""
117
+ return (self.now.month, self.now.day) in self.HOLIDAYS
118
+
119
+ def get_holiday_name(self) -> str | None:
120
+ """Get the name of today's holiday, if any."""
121
+ return self.HOLIDAYS.get((self.now.month, self.now.day))
122
+
123
+ def get_info(self) -> TemporalInfo:
124
+ """Get complete temporal context information."""
125
+ return TemporalInfo(
126
+ time_period=self.get_time_period(),
127
+ day_type=self.get_day_type(),
128
+ season=self.get_season(),
129
+ is_holiday=self.is_holiday(),
130
+ hour=self.now.hour,
131
+ day_of_week=self.now.weekday(),
132
+ month=self.now.month,
133
+ day=self.now.day,
134
+ )
135
+
136
+ def get_music_prompt(self) -> str:
137
+ """Generate a music prompt based on temporal context.
138
+
139
+ Used for AI music generation.
140
+ """
141
+ info = self.get_info()
142
+ parts = []
143
+
144
+ # Time of day
145
+ time_moods = {
146
+ TimePeriod.MORNING: "uplifting, energizing morning",
147
+ TimePeriod.AFTERNOON: "focused, productive afternoon",
148
+ TimePeriod.EVENING: "relaxing, unwinding evening",
149
+ TimePeriod.NIGHT: "calm, peaceful night",
150
+ }
151
+ parts.append(time_moods[info.time_period])
152
+
153
+ # Day type
154
+ if info.day_type == DayType.WEEKEND:
155
+ parts.append("weekend vibes")
156
+
157
+ # Season
158
+ season_moods = {
159
+ Season.SPRING: "fresh spring",
160
+ Season.SUMMER: "warm summer",
161
+ Season.AUTUMN: "cozy autumn",
162
+ Season.WINTER: "serene winter",
163
+ }
164
+ parts.append(season_moods[info.season])
165
+
166
+ # Holiday
167
+ holiday = self.get_holiday_name()
168
+ if holiday:
169
+ parts.append(f"{holiday} celebration")
170
+
171
+ return ", ".join(parts) + " music"