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/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,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"
|