piwave 2.0.3__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.
- piwave/__init__.py +3 -0
- piwave/piwave.py +534 -0
- piwave-2.0.3.dist-info/METADATA +219 -0
- piwave-2.0.3.dist-info/RECORD +7 -0
- piwave-2.0.3.dist-info/WHEEL +5 -0
- piwave-2.0.3.dist-info/licenses/LICENSE +674 -0
- piwave-2.0.3.dist-info/top_level.txt +1 -0
piwave/__init__.py
ADDED
piwave/piwave.py
ADDED
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
# piwave/piwave.py
|
|
2
|
+
# pi_fm_rds is required !!! Check https://github.com/ChristopheJacquet/PiFmRds
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import signal
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import asyncio
|
|
10
|
+
import tempfile
|
|
11
|
+
import shutil
|
|
12
|
+
import sys
|
|
13
|
+
from typing import List, Optional, Callable
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from urllib.parse import urlparse
|
|
16
|
+
|
|
17
|
+
class Log:
|
|
18
|
+
COLORS = { # absolutely not taken from stackoverflow trust
|
|
19
|
+
'reset': '\033[0m',
|
|
20
|
+
'bold': '\033[1m',
|
|
21
|
+
'underline': '\033[4m',
|
|
22
|
+
'red': '\033[31m',
|
|
23
|
+
'green': '\033[32m',
|
|
24
|
+
'yellow': '\033[33m',
|
|
25
|
+
'blue': '\033[34m',
|
|
26
|
+
'magenta': '\033[35m',
|
|
27
|
+
'cyan': '\033[36m',
|
|
28
|
+
'white': '\033[37m',
|
|
29
|
+
'bright_red': '\033[91m',
|
|
30
|
+
'bright_green': '\033[92m',
|
|
31
|
+
'bright_yellow': '\033[93m',
|
|
32
|
+
'bright_blue': '\033[94m',
|
|
33
|
+
'bright_magenta': '\033[95m',
|
|
34
|
+
'bright_cyan': '\033[96m',
|
|
35
|
+
'bright_white': '\033[97m',
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
ICONS = {
|
|
39
|
+
'success': 'OK',
|
|
40
|
+
'error': 'ERR',
|
|
41
|
+
'warning': 'WARN',
|
|
42
|
+
'info': 'INFO',
|
|
43
|
+
'client': 'CLIENT',
|
|
44
|
+
'server': 'SERVER',
|
|
45
|
+
'file': 'FILE',
|
|
46
|
+
'broadcast': 'BCAST',
|
|
47
|
+
'version': 'VER',
|
|
48
|
+
'update': 'UPD',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@classmethod
|
|
52
|
+
def print(cls, message: str, style: str = '', icon: str = '', end: str = '\n'):
|
|
53
|
+
color = cls.COLORS.get(style, '')
|
|
54
|
+
icon_char = cls.ICONS.get(icon, '')
|
|
55
|
+
if icon_char:
|
|
56
|
+
if color:
|
|
57
|
+
print(f"{color}[{icon_char}]\033[0m {message}", end=end)
|
|
58
|
+
else:
|
|
59
|
+
print(f"[{icon_char}] {message}", end=end)
|
|
60
|
+
else:
|
|
61
|
+
if color:
|
|
62
|
+
print(f"{color}{message}\033[0m", end=end)
|
|
63
|
+
else:
|
|
64
|
+
print(f"{message}", end=end)
|
|
65
|
+
sys.stdout.flush()
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def header(cls, text: str):
|
|
69
|
+
cls.print(text, 'bright_blue', end='\n\n')
|
|
70
|
+
sys.stdout.flush()
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def section(cls, text: str):
|
|
74
|
+
cls.print(f" {text} ", 'bright_blue', end='')
|
|
75
|
+
cls.print("─" * (len(text) + 2), 'blue', end='\n\n')
|
|
76
|
+
sys.stdout.flush()
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def success(cls, message: str):
|
|
80
|
+
cls.print(message, 'bright_green', 'success')
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def error(cls, message: str):
|
|
84
|
+
cls.print(message, 'bright_red', 'error')
|
|
85
|
+
|
|
86
|
+
@classmethod
|
|
87
|
+
def warning(cls, message: str):
|
|
88
|
+
cls.print(message, 'bright_yellow', 'warning')
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def info(cls, message: str):
|
|
92
|
+
cls.print(message, 'bright_cyan', 'info')
|
|
93
|
+
|
|
94
|
+
@classmethod
|
|
95
|
+
def file_message(cls, message: str):
|
|
96
|
+
cls.print(message, 'yellow', 'file')
|
|
97
|
+
|
|
98
|
+
@classmethod
|
|
99
|
+
def broadcast_message(cls, message: str):
|
|
100
|
+
cls.print(message, 'bright_magenta', 'broadcast')
|
|
101
|
+
|
|
102
|
+
class PiWaveError(Exception):
|
|
103
|
+
pass
|
|
104
|
+
|
|
105
|
+
class PiWave:
|
|
106
|
+
def __init__(self,
|
|
107
|
+
frequency: float = 90.0,
|
|
108
|
+
ps: str = "PiWave",
|
|
109
|
+
rt: str = "PiWave: The best python module for managing your pi radio",
|
|
110
|
+
pi: str = "FFFF",
|
|
111
|
+
loop: bool = False,
|
|
112
|
+
debug: bool = False,
|
|
113
|
+
on_track_change: Optional[Callable] = None,
|
|
114
|
+
on_error: Optional[Callable] = None):
|
|
115
|
+
|
|
116
|
+
self.debug = debug
|
|
117
|
+
self.frequency = frequency
|
|
118
|
+
self.ps = str(ps)[:8]
|
|
119
|
+
self.rt = str(rt)[:64]
|
|
120
|
+
self.pi = str(pi).upper()[:4]
|
|
121
|
+
self.loop = loop
|
|
122
|
+
self.on_track_change = on_track_change
|
|
123
|
+
self.on_error = on_error
|
|
124
|
+
|
|
125
|
+
self.playlist: List[str] = []
|
|
126
|
+
self.converted_files: dict[str, str] = {}
|
|
127
|
+
self.current_index = 0
|
|
128
|
+
self.is_playing = False
|
|
129
|
+
self.is_stopped = False
|
|
130
|
+
|
|
131
|
+
self.current_process: Optional[subprocess.Popen] = None
|
|
132
|
+
self.playback_thread: Optional[threading.Thread] = None
|
|
133
|
+
self.stop_event = threading.Event()
|
|
134
|
+
|
|
135
|
+
self.temp_dir = tempfile.mkdtemp(prefix="piwave_")
|
|
136
|
+
self.stream_process: Optional[subprocess.Popen] = None
|
|
137
|
+
|
|
138
|
+
self.pi_fm_rds_path = self._find_pi_fm_rds_path()
|
|
139
|
+
|
|
140
|
+
self._validate_environment()
|
|
141
|
+
|
|
142
|
+
signal.signal(signal.SIGINT, self._handle_interrupt)
|
|
143
|
+
signal.signal(signal.SIGTERM, self._handle_interrupt)
|
|
144
|
+
|
|
145
|
+
Log.info(f"PiWave initialized - Frequency: {frequency}MHz, PS: {ps}, Loop: {loop}")
|
|
146
|
+
|
|
147
|
+
def _log_debug(self, message: str):
|
|
148
|
+
if self.debug:
|
|
149
|
+
Log.print(f"[DEBUG] {message}", 'blue')
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _validate_environment(self):
|
|
153
|
+
|
|
154
|
+
#validate that we're running on a Raspberry Pi as root
|
|
155
|
+
|
|
156
|
+
if not self._is_raspberry_pi():
|
|
157
|
+
raise PiWaveError("This program must be run on a Raspberry Pi")
|
|
158
|
+
|
|
159
|
+
if not self._is_root():
|
|
160
|
+
raise PiWaveError("This program must be run as root")
|
|
161
|
+
|
|
162
|
+
def _is_raspberry_pi(self) -> bool:
|
|
163
|
+
try:
|
|
164
|
+
with open("/sys/firmware/devicetree/base/model", "r") as f:
|
|
165
|
+
model = f.read().strip()
|
|
166
|
+
return "Raspberry Pi" in model
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
def _is_root(self) -> bool:
|
|
171
|
+
return os.geteuid() == 0
|
|
172
|
+
|
|
173
|
+
def _find_pi_fm_rds_path(self) -> str:
|
|
174
|
+
current_dir = Path(__file__).parent
|
|
175
|
+
cache_file = current_dir / "pi_fm_rds_path"
|
|
176
|
+
|
|
177
|
+
if cache_file.exists():
|
|
178
|
+
try:
|
|
179
|
+
cached_path = cache_file.read_text().strip()
|
|
180
|
+
if self._is_valid_executable(cached_path):
|
|
181
|
+
return cached_path
|
|
182
|
+
else:
|
|
183
|
+
cache_file.unlink()
|
|
184
|
+
except Exception as e:
|
|
185
|
+
Log.warning(f"Error reading cache file: {e}")
|
|
186
|
+
cache_file.unlink(missing_ok=True)
|
|
187
|
+
|
|
188
|
+
search_paths = ["/opt", "/usr/local/bin", "/usr/bin", "/bin", "/home"]
|
|
189
|
+
|
|
190
|
+
for search_path in search_paths:
|
|
191
|
+
if not Path(search_path).exists():
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
try:
|
|
195
|
+
for root, dirs, files in os.walk(search_path):
|
|
196
|
+
if "pi_fm_rds" in files:
|
|
197
|
+
executable_path = Path(root) / "pi_fm_rds"
|
|
198
|
+
if self._is_valid_executable(str(executable_path)):
|
|
199
|
+
cache_file.write_text(str(executable_path))
|
|
200
|
+
return str(executable_path)
|
|
201
|
+
except (PermissionError, OSError):
|
|
202
|
+
continue
|
|
203
|
+
|
|
204
|
+
print("Could not automatically find `pi_fm_rds`. Please enter the full path manually.")
|
|
205
|
+
user_path = input("Enter the path to `pi_fm_rds`: ").strip()
|
|
206
|
+
|
|
207
|
+
if self._is_valid_executable(user_path):
|
|
208
|
+
cache_file.write_text(user_path)
|
|
209
|
+
return user_path
|
|
210
|
+
|
|
211
|
+
raise PiWaveError("Invalid pi_fm_rds path provided")
|
|
212
|
+
|
|
213
|
+
def _is_valid_executable(self, path: str) -> bool:
|
|
214
|
+
try:
|
|
215
|
+
result = subprocess.run(
|
|
216
|
+
[path, "--help"],
|
|
217
|
+
stdout=subprocess.PIPE,
|
|
218
|
+
stderr=subprocess.PIPE,
|
|
219
|
+
timeout=5
|
|
220
|
+
)
|
|
221
|
+
return result.returncode == 0
|
|
222
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, FileNotFoundError):
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
def _is_url(self, path: str) -> bool:
|
|
226
|
+
parsed = urlparse(path)
|
|
227
|
+
return parsed.scheme in ('http', 'https', 'ftp')
|
|
228
|
+
|
|
229
|
+
def _is_wav_file(self, filepath: str) -> bool:
|
|
230
|
+
return filepath.lower().endswith('.wav')
|
|
231
|
+
|
|
232
|
+
async def _download_stream_chunk(self, url: str, output_file: str, duration: int = 30) -> bool:
|
|
233
|
+
#Download a chunk of stream for specified duration
|
|
234
|
+
try:
|
|
235
|
+
cmd = [
|
|
236
|
+
'ffmpeg', '-i', url, '-t', str(duration),
|
|
237
|
+
'-acodec', 'pcm_s16le', '-ar', '44100', '-ac', '2',
|
|
238
|
+
'-y', output_file
|
|
239
|
+
]
|
|
240
|
+
|
|
241
|
+
process = await asyncio.create_subprocess_exec(
|
|
242
|
+
*cmd,
|
|
243
|
+
stdout=asyncio.subprocess.PIPE,
|
|
244
|
+
stderr=asyncio.subprocess.PIPE
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=duration + 10)
|
|
248
|
+
return process.returncode == 0
|
|
249
|
+
|
|
250
|
+
except asyncio.TimeoutError:
|
|
251
|
+
Log.error(f"Timeout downloading stream chunk from {url}")
|
|
252
|
+
return False
|
|
253
|
+
except Exception as e:
|
|
254
|
+
Log.error(f"Error downloading stream: {e}")
|
|
255
|
+
return False
|
|
256
|
+
|
|
257
|
+
def _convert_to_wav(self, filepath: str) -> Optional[str]:
|
|
258
|
+
if filepath in self.converted_files:
|
|
259
|
+
return self.converted_files[filepath]
|
|
260
|
+
|
|
261
|
+
if self._is_wav_file(filepath) and not self._is_url(filepath):
|
|
262
|
+
self.converted_files[filepath] = filepath
|
|
263
|
+
return filepath
|
|
264
|
+
|
|
265
|
+
Log.file_message(f"Converting {filepath} to WAV")
|
|
266
|
+
|
|
267
|
+
if self._is_url(filepath):
|
|
268
|
+
output_file = os.path.join(self.temp_dir, f"stream_{int(time.time())}.wav")
|
|
269
|
+
else:
|
|
270
|
+
output_file = f"{os.path.splitext(filepath)[0]}_converted.wav"
|
|
271
|
+
|
|
272
|
+
cmd = [
|
|
273
|
+
'ffmpeg', '-i', filepath, '-acodec', 'pcm_s16le',
|
|
274
|
+
'-ar', '44100', '-ac', '2', '-y', output_file
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
cmd,
|
|
280
|
+
stdout=subprocess.PIPE,
|
|
281
|
+
stderr=subprocess.PIPE,
|
|
282
|
+
timeout=60, # 60 seconds timeout
|
|
283
|
+
check=True
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
self._log_debug(f"FFmpeg conversion successful for {filepath}")
|
|
287
|
+
|
|
288
|
+
self.converted_files[filepath] = output_file
|
|
289
|
+
return output_file
|
|
290
|
+
|
|
291
|
+
except subprocess.TimeoutExpired:
|
|
292
|
+
Log.error(f"Conversion timeout for {filepath}")
|
|
293
|
+
return None
|
|
294
|
+
except subprocess.CalledProcessError as e:
|
|
295
|
+
Log.error(f"Conversion failed for {filepath}: {e.stderr.decode()}")
|
|
296
|
+
return None
|
|
297
|
+
except Exception as e:
|
|
298
|
+
Log.error(f"Unexpected error converting {filepath}: {e}")
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
def _get_file_duration(self, wav_file: str) -> float:
|
|
302
|
+
cmd = [
|
|
303
|
+
'ffprobe', '-i', wav_file, '-show_entries', 'format=duration',
|
|
304
|
+
'-v', 'quiet', '-of', 'csv=p=0'
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
result = subprocess.run(
|
|
309
|
+
cmd,
|
|
310
|
+
stdout=subprocess.PIPE,
|
|
311
|
+
stderr=subprocess.PIPE,
|
|
312
|
+
timeout=10,
|
|
313
|
+
check=True
|
|
314
|
+
)
|
|
315
|
+
return float(result.stdout.decode().strip())
|
|
316
|
+
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
|
|
317
|
+
return 0.0
|
|
318
|
+
|
|
319
|
+
def _play_wav(self, wav_file: str) -> bool:
|
|
320
|
+
if self.stop_event.is_set():
|
|
321
|
+
return False
|
|
322
|
+
|
|
323
|
+
duration = self._get_file_duration(wav_file)
|
|
324
|
+
if duration <= 0:
|
|
325
|
+
Log.error(f"Could not determine duration for {wav_file}")
|
|
326
|
+
return False
|
|
327
|
+
|
|
328
|
+
cmd = [
|
|
329
|
+
'sudo', self.pi_fm_rds_path,
|
|
330
|
+
'-freq', str(self.frequency),
|
|
331
|
+
'-ps', self.ps,
|
|
332
|
+
'-rt', self.rt,
|
|
333
|
+
'-pi', self.pi,
|
|
334
|
+
'-audio', wav_file
|
|
335
|
+
]
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
Log.broadcast_message(f"Playing {wav_file} (Duration: {duration:.1f}s) at {self.frequency}MHz")
|
|
339
|
+
self.current_process = subprocess.Popen(
|
|
340
|
+
cmd,
|
|
341
|
+
stdout=subprocess.PIPE,
|
|
342
|
+
stderr=subprocess.PIPE,
|
|
343
|
+
preexec_fn=os.setsid
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if self.on_track_change:
|
|
347
|
+
self.on_track_change(wav_file, self.current_index)
|
|
348
|
+
|
|
349
|
+
# wait for either
|
|
350
|
+
# The duration to elapse (then kill the process), or
|
|
351
|
+
# stop_event to be set (user requested stop)
|
|
352
|
+
start_time = time.time()
|
|
353
|
+
while True:
|
|
354
|
+
if self.stop_event.wait(timeout=0.1):
|
|
355
|
+
self._stop_current_process()
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
elapsed = time.time() - start_time
|
|
359
|
+
if elapsed >= duration:
|
|
360
|
+
self._stop_current_process()
|
|
361
|
+
break
|
|
362
|
+
|
|
363
|
+
return True
|
|
364
|
+
|
|
365
|
+
except Exception as e:
|
|
366
|
+
Log.error(f"Error playing {wav_file}: {e}")
|
|
367
|
+
if self.on_error:
|
|
368
|
+
self.on_error(e)
|
|
369
|
+
self._stop_current_process()
|
|
370
|
+
return False
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _stop_current_process(self):
|
|
374
|
+
if self.current_process:
|
|
375
|
+
try:
|
|
376
|
+
Log.info("Stopping current process...")
|
|
377
|
+
os.killpg(os.getpgid(self.current_process.pid), signal.SIGTERM)
|
|
378
|
+
self.current_process.wait(timeout=5)
|
|
379
|
+
except (ProcessLookupError, subprocess.TimeoutExpired):
|
|
380
|
+
Log.warning("Forcing kill of current process")
|
|
381
|
+
try:
|
|
382
|
+
os.killpg(os.getpgid(self.current_process.pid), signal.SIGKILL)
|
|
383
|
+
except ProcessLookupError:
|
|
384
|
+
pass
|
|
385
|
+
finally:
|
|
386
|
+
self.current_process = None
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _playback_worker(self):
|
|
390
|
+
self._log_debug("Playback worker started")
|
|
391
|
+
|
|
392
|
+
while not self.stop_event.is_set() and not self.is_stopped:
|
|
393
|
+
if self.current_index >= len(self.playlist):
|
|
394
|
+
if self.loop:
|
|
395
|
+
self.current_index = 0
|
|
396
|
+
continue
|
|
397
|
+
else:
|
|
398
|
+
break
|
|
399
|
+
|
|
400
|
+
if self.current_index < len(self.playlist):
|
|
401
|
+
wav_file = self.playlist[self.current_index]
|
|
402
|
+
|
|
403
|
+
if not os.path.exists(wav_file):
|
|
404
|
+
Log.error(f"File not found: {wav_file}")
|
|
405
|
+
self.current_index += 1
|
|
406
|
+
continue
|
|
407
|
+
|
|
408
|
+
if not self._play_wav(wav_file):
|
|
409
|
+
if not self.stop_event.is_set():
|
|
410
|
+
Log.error(f"Playback failed for {wav_file}")
|
|
411
|
+
break
|
|
412
|
+
|
|
413
|
+
self.current_index += 1
|
|
414
|
+
|
|
415
|
+
self.is_playing = False
|
|
416
|
+
self._log_debug("Playback worker finished")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _handle_interrupt(self, signum, frame):
|
|
420
|
+
Log.warning("Interrupt received, stopping playback...")
|
|
421
|
+
self.stop()
|
|
422
|
+
os._exit(0)
|
|
423
|
+
|
|
424
|
+
def add_files(self, files: List[str]) -> bool:
|
|
425
|
+
converted_files = []
|
|
426
|
+
|
|
427
|
+
for file_path in files:
|
|
428
|
+
if self._is_url(file_path):
|
|
429
|
+
converted_files.append(file_path)
|
|
430
|
+
else:
|
|
431
|
+
wav_file = self._convert_to_wav(file_path)
|
|
432
|
+
if wav_file:
|
|
433
|
+
converted_files.append(wav_file)
|
|
434
|
+
else:
|
|
435
|
+
Log.warning(f"Failed to convert {file_path}")
|
|
436
|
+
|
|
437
|
+
if converted_files:
|
|
438
|
+
self.playlist.extend(converted_files)
|
|
439
|
+
Log.success(f"Added {len(converted_files)} files to playlist")
|
|
440
|
+
return True
|
|
441
|
+
|
|
442
|
+
return False
|
|
443
|
+
|
|
444
|
+
def play(self, files: Optional[List[str]] = None) -> bool:
|
|
445
|
+
if files:
|
|
446
|
+
self.playlist.clear()
|
|
447
|
+
self.current_index = 0
|
|
448
|
+
if not self.add_files(files):
|
|
449
|
+
return False
|
|
450
|
+
|
|
451
|
+
if not self.playlist:
|
|
452
|
+
Log.warning("No files in playlist")
|
|
453
|
+
return False
|
|
454
|
+
|
|
455
|
+
if self.is_playing:
|
|
456
|
+
self.stop()
|
|
457
|
+
|
|
458
|
+
self.stop_event.clear()
|
|
459
|
+
self.is_stopped = False
|
|
460
|
+
self.is_playing = True
|
|
461
|
+
|
|
462
|
+
self.playback_thread = threading.Thread(target=self._playback_worker)
|
|
463
|
+
self.playback_thread.daemon = True
|
|
464
|
+
self.playback_thread.start()
|
|
465
|
+
|
|
466
|
+
Log.success("Playback started")
|
|
467
|
+
return True
|
|
468
|
+
|
|
469
|
+
def stop(self):
|
|
470
|
+
if not self.is_playing:
|
|
471
|
+
return
|
|
472
|
+
|
|
473
|
+
Log.warning("Stopping playback")
|
|
474
|
+
self.is_stopped = True
|
|
475
|
+
self.stop_event.set()
|
|
476
|
+
|
|
477
|
+
self._stop_current_process()
|
|
478
|
+
|
|
479
|
+
if self.playback_thread and self.playback_thread.is_alive():
|
|
480
|
+
self.playback_thread.join(timeout=5)
|
|
481
|
+
|
|
482
|
+
self.is_playing = False
|
|
483
|
+
Log.success("Playback stopped")
|
|
484
|
+
|
|
485
|
+
def pause(self):
|
|
486
|
+
if self.is_playing:
|
|
487
|
+
self._stop_current_process()
|
|
488
|
+
Log.info("Playback paused")
|
|
489
|
+
|
|
490
|
+
def resume(self):
|
|
491
|
+
if not self.is_playing and self.playlist:
|
|
492
|
+
self.play()
|
|
493
|
+
|
|
494
|
+
def next_track(self):
|
|
495
|
+
if self.is_playing:
|
|
496
|
+
self._stop_current_process()
|
|
497
|
+
self.current_index += 1
|
|
498
|
+
|
|
499
|
+
def previous_track(self):
|
|
500
|
+
if self.is_playing:
|
|
501
|
+
self._stop_current_process()
|
|
502
|
+
self.current_index = max(0, self.current_index - 1)
|
|
503
|
+
|
|
504
|
+
def set_frequency(self, frequency: float):
|
|
505
|
+
self.frequency = frequency
|
|
506
|
+
Log.broadcast_message(f"Frequency changed to {frequency}MHz. Will update on next file's broadcast.")
|
|
507
|
+
|
|
508
|
+
def get_status(self) -> dict:
|
|
509
|
+
return {
|
|
510
|
+
'is_playing': self.is_playing,
|
|
511
|
+
'current_index': self.current_index,
|
|
512
|
+
'playlist_length': len(self.playlist),
|
|
513
|
+
'frequency': self.frequency,
|
|
514
|
+
'current_file': self.playlist[self.current_index] if self.current_index < len(self.playlist) else None
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
def cleanup(self):
|
|
518
|
+
self.stop()
|
|
519
|
+
|
|
520
|
+
if os.path.exists(self.temp_dir):
|
|
521
|
+
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
|
522
|
+
|
|
523
|
+
Log.info("Cleanup completed")
|
|
524
|
+
|
|
525
|
+
def __del__(self):
|
|
526
|
+
self.cleanup()
|
|
527
|
+
|
|
528
|
+
def send(self, files: List[str]):
|
|
529
|
+
return self.play(files)
|
|
530
|
+
|
|
531
|
+
def restart(self):
|
|
532
|
+
if self.playlist:
|
|
533
|
+
self.current_index = 0
|
|
534
|
+
self.play()
|