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
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""FFplay-based audio player implementation."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import shutil
|
|
6
|
+
import signal
|
|
7
|
+
|
|
8
|
+
from .base import Player, PlayerState, TrackInfo
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FFplayPlayer(Player):
|
|
14
|
+
"""Audio player using ffplay (part of FFmpeg)."""
|
|
15
|
+
|
|
16
|
+
def __init__(self):
|
|
17
|
+
super().__init__()
|
|
18
|
+
self._process: asyncio.subprocess.Process | None = None
|
|
19
|
+
self._monitor_task: asyncio.Task | None = None
|
|
20
|
+
self._paused = False
|
|
21
|
+
|
|
22
|
+
# Verify ffplay is available
|
|
23
|
+
if not shutil.which("ffplay"):
|
|
24
|
+
logger.warning("ffplay not found in PATH. Please install FFmpeg.")
|
|
25
|
+
|
|
26
|
+
async def play(self, track: TrackInfo) -> bool:
|
|
27
|
+
"""Start playing a track using ffplay."""
|
|
28
|
+
# Stop any current playback
|
|
29
|
+
await self.stop()
|
|
30
|
+
|
|
31
|
+
self._state = PlayerState.LOADING
|
|
32
|
+
self._current_track = track
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# Build ffplay command
|
|
36
|
+
cmd = [
|
|
37
|
+
"ffplay",
|
|
38
|
+
"-nodisp", # No display window
|
|
39
|
+
"-autoexit", # Exit when done (for files)
|
|
40
|
+
"-loglevel",
|
|
41
|
+
"quiet", # Suppress output
|
|
42
|
+
"-volume",
|
|
43
|
+
str(self._volume),
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
# For streams, add reconnect options
|
|
47
|
+
if track.source_type == "radio":
|
|
48
|
+
cmd.extend(
|
|
49
|
+
[
|
|
50
|
+
"-reconnect",
|
|
51
|
+
"1",
|
|
52
|
+
"-reconnect_streamed",
|
|
53
|
+
"1",
|
|
54
|
+
"-reconnect_delay_max",
|
|
55
|
+
"5",
|
|
56
|
+
]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
cmd.append(track.source)
|
|
60
|
+
|
|
61
|
+
logger.info(f"Starting playback: {track.source}")
|
|
62
|
+
|
|
63
|
+
self._process = await asyncio.create_subprocess_exec(
|
|
64
|
+
*cmd,
|
|
65
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
66
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
67
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self._state = PlayerState.PLAYING
|
|
71
|
+
self._paused = False
|
|
72
|
+
|
|
73
|
+
# Start monitoring for process end
|
|
74
|
+
self._monitor_task = asyncio.create_task(self._monitor_playback())
|
|
75
|
+
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
except Exception as e:
|
|
79
|
+
logger.error(f"Failed to start playback: {e}")
|
|
80
|
+
self._state = PlayerState.ERROR
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
async def _monitor_playback(self) -> None:
|
|
84
|
+
"""Monitor the ffplay process and handle completion."""
|
|
85
|
+
if self._process is None:
|
|
86
|
+
return
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
await self._process.wait()
|
|
90
|
+
|
|
91
|
+
# Only trigger callback if we weren't stopped manually
|
|
92
|
+
if self._state == PlayerState.PLAYING:
|
|
93
|
+
self._state = PlayerState.STOPPED
|
|
94
|
+
self._current_track = None
|
|
95
|
+
|
|
96
|
+
if self._on_track_end:
|
|
97
|
+
self._on_track_end()
|
|
98
|
+
|
|
99
|
+
except asyncio.CancelledError:
|
|
100
|
+
pass
|
|
101
|
+
except Exception as e:
|
|
102
|
+
logger.error(f"Error monitoring playback: {e}")
|
|
103
|
+
|
|
104
|
+
async def stop(self) -> None:
|
|
105
|
+
"""Stop playback."""
|
|
106
|
+
if self._monitor_task:
|
|
107
|
+
self._monitor_task.cancel()
|
|
108
|
+
try:
|
|
109
|
+
await self._monitor_task
|
|
110
|
+
except asyncio.CancelledError:
|
|
111
|
+
pass
|
|
112
|
+
self._monitor_task = None
|
|
113
|
+
|
|
114
|
+
if self._process:
|
|
115
|
+
try:
|
|
116
|
+
self._process.terminate()
|
|
117
|
+
try:
|
|
118
|
+
await asyncio.wait_for(self._process.wait(), timeout=2.0)
|
|
119
|
+
except asyncio.TimeoutError:
|
|
120
|
+
self._process.kill()
|
|
121
|
+
await self._process.wait()
|
|
122
|
+
except ProcessLookupError:
|
|
123
|
+
pass # Process already ended
|
|
124
|
+
self._process = None
|
|
125
|
+
|
|
126
|
+
self._state = PlayerState.STOPPED
|
|
127
|
+
self._current_track = None
|
|
128
|
+
self._paused = False
|
|
129
|
+
|
|
130
|
+
async def pause(self) -> None:
|
|
131
|
+
"""Pause playback by sending SIGSTOP to ffplay."""
|
|
132
|
+
if self._process and self._state == PlayerState.PLAYING:
|
|
133
|
+
try:
|
|
134
|
+
self._process.send_signal(signal.SIGSTOP)
|
|
135
|
+
self._state = PlayerState.PAUSED
|
|
136
|
+
self._paused = True
|
|
137
|
+
except ProcessLookupError:
|
|
138
|
+
pass
|
|
139
|
+
|
|
140
|
+
async def resume(self) -> None:
|
|
141
|
+
"""Resume playback by sending SIGCONT to ffplay."""
|
|
142
|
+
if self._process and self._state == PlayerState.PAUSED:
|
|
143
|
+
try:
|
|
144
|
+
self._process.send_signal(signal.SIGCONT)
|
|
145
|
+
self._state = PlayerState.PLAYING
|
|
146
|
+
self._paused = False
|
|
147
|
+
except ProcessLookupError:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
async def set_volume(self, volume: int) -> None:
|
|
151
|
+
"""Set volume. Note: ffplay doesn't support runtime volume changes.
|
|
152
|
+
|
|
153
|
+
Volume will apply to the next track.
|
|
154
|
+
"""
|
|
155
|
+
self._volume = max(0, min(100, volume))
|
|
156
|
+
# ffplay doesn't support dynamic volume changes
|
|
157
|
+
# The volume will be applied when the next track starts
|
|
158
|
+
logger.info(f"Volume set to {self._volume}% (applies to next track)")
|
|
159
|
+
|
|
160
|
+
async def get_position(self) -> float:
|
|
161
|
+
"""Get current playback position.
|
|
162
|
+
|
|
163
|
+
Note: ffplay doesn't provide easy position tracking.
|
|
164
|
+
This is a limitation of the ffplay approach.
|
|
165
|
+
"""
|
|
166
|
+
# ffplay doesn't expose position information easily
|
|
167
|
+
# For accurate position tracking, we'd need mpv or VLC
|
|
168
|
+
return 0.0
|
|
169
|
+
|
|
170
|
+
def get_status(self) -> dict:
|
|
171
|
+
"""Get current player status."""
|
|
172
|
+
status = super().get_status()
|
|
173
|
+
status["backend"] = "ffplay"
|
|
174
|
+
return status
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def check_ffplay_available() -> bool:
|
|
178
|
+
"""Check if ffplay is available in PATH."""
|
|
179
|
+
return shutil.which("ffplay") is not None
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""AI music generation using MusicGen (optional feature)."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..player.base import TrackInfo
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
# Flag to track if AI dependencies are available
|
|
12
|
+
_AI_AVAILABLE: bool | None = None
|
|
13
|
+
_musicgen_model = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_ai_available() -> bool:
|
|
17
|
+
"""Check if AI music generation dependencies are available."""
|
|
18
|
+
global _AI_AVAILABLE
|
|
19
|
+
|
|
20
|
+
if _AI_AVAILABLE is not None:
|
|
21
|
+
return _AI_AVAILABLE
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import torch # noqa: F401
|
|
25
|
+
from audiocraft.models import MusicGen # noqa: F401
|
|
26
|
+
|
|
27
|
+
_AI_AVAILABLE = True
|
|
28
|
+
logger.info("AI music generation is available")
|
|
29
|
+
except ImportError as e:
|
|
30
|
+
_AI_AVAILABLE = False
|
|
31
|
+
logger.info(f"AI music generation not available: {e}")
|
|
32
|
+
|
|
33
|
+
return _AI_AVAILABLE
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _get_model():
|
|
37
|
+
"""Lazy-load the MusicGen model."""
|
|
38
|
+
global _musicgen_model
|
|
39
|
+
|
|
40
|
+
if not is_ai_available():
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
if _musicgen_model is None:
|
|
44
|
+
try:
|
|
45
|
+
from audiocraft.models import MusicGen
|
|
46
|
+
|
|
47
|
+
logger.info("Loading MusicGen model (this may take a moment)...")
|
|
48
|
+
# Use the small model for faster loading and lower memory usage
|
|
49
|
+
_musicgen_model = MusicGen.get_pretrained("facebook/musicgen-small")
|
|
50
|
+
_musicgen_model.set_generation_params(duration=30) # 30 seconds default
|
|
51
|
+
logger.info("MusicGen model loaded successfully")
|
|
52
|
+
except Exception as e:
|
|
53
|
+
logger.error(f"Failed to load MusicGen model: {e}")
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
return _musicgen_model
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AIGenerator:
|
|
60
|
+
"""Generates music using Meta's MusicGen model."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, output_dir: Path | None = None):
|
|
63
|
+
"""Initialize AI generator.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
output_dir: Directory to save generated audio files.
|
|
67
|
+
Defaults to a temp directory.
|
|
68
|
+
"""
|
|
69
|
+
if output_dir is None:
|
|
70
|
+
output_dir = Path(tempfile.gettempdir()) / "music-cli-ai"
|
|
71
|
+
self.output_dir = output_dir
|
|
72
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def available(self) -> bool:
|
|
76
|
+
"""Check if AI generation is available."""
|
|
77
|
+
return is_ai_available()
|
|
78
|
+
|
|
79
|
+
def generate(
|
|
80
|
+
self,
|
|
81
|
+
prompt: str,
|
|
82
|
+
duration: int = 30,
|
|
83
|
+
filename: str | None = None,
|
|
84
|
+
) -> TrackInfo | None:
|
|
85
|
+
"""Generate music from a text prompt.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
prompt: Text description of the music to generate.
|
|
89
|
+
duration: Duration in seconds (5-300).
|
|
90
|
+
filename: Optional output filename.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
TrackInfo for the generated audio, or None if generation failed.
|
|
94
|
+
"""
|
|
95
|
+
model = _get_model()
|
|
96
|
+
if model is None:
|
|
97
|
+
logger.warning("AI model not available")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
import scipy.io.wavfile
|
|
102
|
+
|
|
103
|
+
# Clamp duration
|
|
104
|
+
duration = max(5, min(300, duration))
|
|
105
|
+
model.set_generation_params(duration=duration)
|
|
106
|
+
|
|
107
|
+
logger.info(f"Generating {duration}s of music: {prompt[:50]}...")
|
|
108
|
+
|
|
109
|
+
# Generate audio
|
|
110
|
+
wav = model.generate([prompt])
|
|
111
|
+
|
|
112
|
+
# Generate filename if not provided
|
|
113
|
+
if filename is None:
|
|
114
|
+
import hashlib
|
|
115
|
+
import time
|
|
116
|
+
|
|
117
|
+
hash_input = f"{prompt}{time.time()}"
|
|
118
|
+
short_hash = hashlib.md5( # noqa: S324
|
|
119
|
+
hash_input.encode(), usedforsecurity=False
|
|
120
|
+
).hexdigest()[:8]
|
|
121
|
+
filename = f"ai_music_{short_hash}.wav"
|
|
122
|
+
|
|
123
|
+
# Save to file
|
|
124
|
+
output_path = self.output_dir / filename
|
|
125
|
+
|
|
126
|
+
# Get the audio tensor and sample rate
|
|
127
|
+
audio = wav[0].cpu().numpy()
|
|
128
|
+
sample_rate = model.sample_rate
|
|
129
|
+
|
|
130
|
+
# Save as WAV
|
|
131
|
+
scipy.io.wavfile.write(str(output_path), sample_rate, audio.T)
|
|
132
|
+
|
|
133
|
+
logger.info(f"Generated audio saved to: {output_path}")
|
|
134
|
+
|
|
135
|
+
return TrackInfo(
|
|
136
|
+
source=str(output_path),
|
|
137
|
+
source_type="ai",
|
|
138
|
+
title=f"AI: {prompt[:40]}...",
|
|
139
|
+
metadata={
|
|
140
|
+
"prompt": prompt,
|
|
141
|
+
"duration": duration,
|
|
142
|
+
"model": "musicgen-small",
|
|
143
|
+
},
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
except Exception as e:
|
|
147
|
+
logger.error(f"Failed to generate music: {e}")
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
def generate_for_context(
|
|
151
|
+
self,
|
|
152
|
+
mood_prompt: str | None = None,
|
|
153
|
+
temporal_prompt: str | None = None,
|
|
154
|
+
duration: int = 30,
|
|
155
|
+
) -> TrackInfo | None:
|
|
156
|
+
"""Generate context-appropriate music.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
mood_prompt: Mood-based prompt component.
|
|
160
|
+
temporal_prompt: Time-based prompt component.
|
|
161
|
+
duration: Duration in seconds.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
TrackInfo for the generated audio.
|
|
165
|
+
"""
|
|
166
|
+
prompts = []
|
|
167
|
+
|
|
168
|
+
if mood_prompt:
|
|
169
|
+
prompts.append(mood_prompt)
|
|
170
|
+
if temporal_prompt:
|
|
171
|
+
prompts.append(temporal_prompt)
|
|
172
|
+
|
|
173
|
+
if not prompts:
|
|
174
|
+
prompts.append("ambient background music")
|
|
175
|
+
|
|
176
|
+
full_prompt = ", ".join(prompts)
|
|
177
|
+
return self.generate(full_prompt, duration)
|
|
178
|
+
|
|
179
|
+
def cleanup_old_files(self, max_age_hours: int = 24) -> int:
|
|
180
|
+
"""Clean up old generated files.
|
|
181
|
+
|
|
182
|
+
Args:
|
|
183
|
+
max_age_hours: Maximum age of files to keep.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Number of files deleted.
|
|
187
|
+
"""
|
|
188
|
+
import time
|
|
189
|
+
|
|
190
|
+
deleted = 0
|
|
191
|
+
cutoff = time.time() - (max_age_hours * 3600)
|
|
192
|
+
|
|
193
|
+
for f in self.output_dir.glob("ai_music_*.wav"):
|
|
194
|
+
if f.stat().st_mtime < cutoff:
|
|
195
|
+
try:
|
|
196
|
+
f.unlink()
|
|
197
|
+
deleted += 1
|
|
198
|
+
except OSError:
|
|
199
|
+
pass
|
|
200
|
+
|
|
201
|
+
return deleted
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# Provide helpful error message if AI is not available
|
|
205
|
+
def get_ai_install_instructions() -> str:
|
|
206
|
+
"""Get instructions for installing AI dependencies."""
|
|
207
|
+
return """
|
|
208
|
+
AI music generation requires additional dependencies.
|
|
209
|
+
Install them with:
|
|
210
|
+
|
|
211
|
+
pip install 'music-cli[ai]'
|
|
212
|
+
|
|
213
|
+
Or install manually:
|
|
214
|
+
|
|
215
|
+
pip install torch transformers audiocraft
|
|
216
|
+
|
|
217
|
+
Note: This requires significant disk space (~5GB) and RAM (~8GB minimum).
|
|
218
|
+
The first generation will download the MusicGen model (~3GB).
|
|
219
|
+
"""
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Local MP3 file source."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from ..player.base import TrackInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class LocalSource:
|
|
10
|
+
"""Handles local MP3 file playback."""
|
|
11
|
+
|
|
12
|
+
SUPPORTED_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".ogg", ".opus"}
|
|
13
|
+
|
|
14
|
+
def __init__(self, music_dir: Path | None = None):
|
|
15
|
+
"""Initialize with optional music directory."""
|
|
16
|
+
if music_dir is None:
|
|
17
|
+
# Default to ~/Music
|
|
18
|
+
music_dir = Path("~/Music").expanduser()
|
|
19
|
+
self.music_dir = music_dir
|
|
20
|
+
|
|
21
|
+
def get_track(self, path: str) -> TrackInfo | None:
|
|
22
|
+
"""Get track info for a specific file path."""
|
|
23
|
+
file_path = Path(path)
|
|
24
|
+
|
|
25
|
+
if not file_path.is_absolute():
|
|
26
|
+
# Try relative to music dir
|
|
27
|
+
file_path = self.music_dir / file_path
|
|
28
|
+
|
|
29
|
+
if not file_path.exists():
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
if file_path.suffix.lower() not in self.SUPPORTED_EXTENSIONS:
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
return TrackInfo(
|
|
36
|
+
source=str(file_path),
|
|
37
|
+
source_type="local",
|
|
38
|
+
title=file_path.stem,
|
|
39
|
+
metadata={"filename": file_path.name},
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def scan_directory(self, directory: Path | None = None) -> list[Path]:
|
|
43
|
+
"""Scan a directory for music files."""
|
|
44
|
+
if directory is None:
|
|
45
|
+
directory = self.music_dir
|
|
46
|
+
|
|
47
|
+
if not directory.exists():
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
files: list[Path] = []
|
|
51
|
+
for ext in self.SUPPORTED_EXTENSIONS:
|
|
52
|
+
files.extend(directory.rglob(f"*{ext}"))
|
|
53
|
+
|
|
54
|
+
return sorted(files)
|
|
55
|
+
|
|
56
|
+
def get_random_track(self, directory: Path | None = None) -> TrackInfo | None:
|
|
57
|
+
"""Get a random track from the directory."""
|
|
58
|
+
files = self.scan_directory(directory)
|
|
59
|
+
if not files:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
chosen = random.choice(files)
|
|
63
|
+
return self.get_track(str(chosen))
|
|
64
|
+
|
|
65
|
+
def list_tracks(self, directory: Path | None = None, limit: int = 50) -> list[TrackInfo]:
|
|
66
|
+
"""List tracks in a directory."""
|
|
67
|
+
files = self.scan_directory(directory)
|
|
68
|
+
tracks = []
|
|
69
|
+
|
|
70
|
+
for f in files[:limit]:
|
|
71
|
+
track = self.get_track(str(f))
|
|
72
|
+
if track:
|
|
73
|
+
tracks.append(track)
|
|
74
|
+
|
|
75
|
+
return tracks
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Radio streaming source."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
from ..config import get_config
|
|
6
|
+
from ..player.base import TrackInfo
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RadioSource:
|
|
10
|
+
"""Handles radio stream playback."""
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
"""Initialize radio source."""
|
|
14
|
+
self.config = get_config()
|
|
15
|
+
|
|
16
|
+
def get_stations(self) -> list[tuple[str, str]]:
|
|
17
|
+
"""Get list of (name, url) tuples for all configured stations."""
|
|
18
|
+
return self.config.get_radios()
|
|
19
|
+
|
|
20
|
+
def get_track(self, url: str, name: str | None = None) -> TrackInfo:
|
|
21
|
+
"""Create a track info for a radio stream."""
|
|
22
|
+
if name is None:
|
|
23
|
+
# Try to find name in config
|
|
24
|
+
for station_name, station_url in self.get_stations():
|
|
25
|
+
if station_url == url:
|
|
26
|
+
name = station_name
|
|
27
|
+
break
|
|
28
|
+
if name is None:
|
|
29
|
+
name = url
|
|
30
|
+
|
|
31
|
+
return TrackInfo(
|
|
32
|
+
source=url,
|
|
33
|
+
source_type="radio",
|
|
34
|
+
title=name,
|
|
35
|
+
metadata={"stream_url": url},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_station_by_name(self, name: str) -> TrackInfo | None:
|
|
39
|
+
"""Get a station by its name (case-insensitive partial match)."""
|
|
40
|
+
name_lower = name.lower()
|
|
41
|
+
|
|
42
|
+
for station_name, url in self.get_stations():
|
|
43
|
+
if name_lower in station_name.lower():
|
|
44
|
+
return self.get_track(url, station_name)
|
|
45
|
+
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
def get_station_by_index(self, index: int) -> TrackInfo | None:
|
|
49
|
+
"""Get a station by its index (1-based)."""
|
|
50
|
+
stations = self.get_stations()
|
|
51
|
+
if 1 <= index <= len(stations):
|
|
52
|
+
name, url = stations[index - 1]
|
|
53
|
+
return self.get_track(url, name)
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
def get_random_station(self) -> TrackInfo | None:
|
|
57
|
+
"""Get a random station."""
|
|
58
|
+
stations = self.get_stations()
|
|
59
|
+
if not stations:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
name, url = random.choice(stations)
|
|
63
|
+
return self.get_track(url, name)
|
|
64
|
+
|
|
65
|
+
def get_mood_station(self, mood: str) -> TrackInfo | None:
|
|
66
|
+
"""Get a station for a specific mood."""
|
|
67
|
+
url = self.config.get_mood_radio(mood)
|
|
68
|
+
if url:
|
|
69
|
+
return self.get_track(url, f"{mood.capitalize()} Radio")
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
def get_time_station(self, time_period: str) -> TrackInfo | None:
|
|
73
|
+
"""Get a station for a time period (morning/afternoon/evening/night)."""
|
|
74
|
+
url = self.config.get_time_radio(time_period)
|
|
75
|
+
if url:
|
|
76
|
+
return self.get_track(url, f"{time_period.capitalize()} Radio")
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
def list_stations(self) -> list[dict]:
|
|
80
|
+
"""List all stations with their indices."""
|
|
81
|
+
stations = []
|
|
82
|
+
for i, (name, url) in enumerate(self.get_stations(), 1):
|
|
83
|
+
stations.append(
|
|
84
|
+
{
|
|
85
|
+
"index": i,
|
|
86
|
+
"name": name,
|
|
87
|
+
"url": url,
|
|
88
|
+
}
|
|
89
|
+
)
|
|
90
|
+
return stations
|