- Add Like button: promotes wave track to permanent library and refills buffer - Add Dislike button: blacklists track in SQLite disliked_tracks, removes audio, refills buffer - Add Delete button in history table for library file removal - Add AI Radio module: wttr.in weather, Qwen 2.5 host monologues, speech synthesis, ID3 tags - Generate interleaved 'AI Радио.m3u8' alongside clean 'Моя Волна.m3u8' - Update Web UI with Radio DJ card, voice selection, and track actions
157 lines
6.4 KiB
Python
157 lines
6.4 KiB
Python
import os
|
|
import shutil
|
|
from typing import List, Dict, Any, Optional
|
|
from app.config import settings
|
|
from app.wave.rotator import wave_rotator
|
|
from app.radio.dj import dj_host
|
|
|
|
class RadioManager:
|
|
def __init__(self):
|
|
self.radio_dir = os.path.join(settings.MUSIC_DIR, "AI Recommendations", "Радио")
|
|
os.makedirs(self.radio_dir, exist_ok=True)
|
|
|
|
def get_existing_speech_files(self) -> List[Dict[str, Any]]:
|
|
"""List currently generated DJ speech clips."""
|
|
if not os.path.exists(self.radio_dir):
|
|
return []
|
|
files = []
|
|
for f in sorted(os.listdir(self.radio_dir)):
|
|
if f.lower().endswith(".mp3"):
|
|
fpath = os.path.join(self.radio_dir, f)
|
|
stat = os.stat(fpath)
|
|
files.append({
|
|
"filename": f,
|
|
"path": fpath,
|
|
"size_kb": round(stat.st_size / 1024, 1),
|
|
"mtime": stat.st_mtime
|
|
})
|
|
return files
|
|
|
|
async def generate_radio_broadcast(
|
|
self,
|
|
voice: Optional[str] = None,
|
|
max_segments: int = 5
|
|
) -> Dict[str, Any]:
|
|
"""Generate AI DJ speech intros and build the 'AI Радио.m3u8' interleaved playlist."""
|
|
wave_tracks = wave_rotator.get_current_buffer()
|
|
if not wave_tracks:
|
|
# If buffer is empty, refill it first
|
|
print("[RadioManager] Wave buffer empty. Sourcing wave tracks first...")
|
|
await wave_rotator.fill_buffer(mood="auto")
|
|
wave_tracks = wave_rotator.get_current_buffer()
|
|
|
|
if not wave_tracks:
|
|
return {"status": "error", "message": "Нет доступных треков в буфере Волны"}
|
|
|
|
# Clean old DJ voice tracks
|
|
for old_file in os.listdir(self.radio_dir):
|
|
if old_file.endswith(".mp3"):
|
|
try:
|
|
os.remove(os.path.join(self.radio_dir, old_file))
|
|
except Exception as e:
|
|
print(f"[RadioManager] Warning removing old radio file: {e}")
|
|
|
|
# Plan DJ speech checkpoints (e.g. before track 0, before track 3, before track 7, etc.)
|
|
total_tracks = len(wave_tracks)
|
|
checkpoints = [0]
|
|
step = max(2, settings.RADIO_INTRO_INTERVAL)
|
|
curr = step
|
|
while curr < total_tracks and len(checkpoints) < max_segments:
|
|
checkpoints.append(curr)
|
|
curr += step
|
|
|
|
generated_segments = []
|
|
speech_map: Dict[int, str] = {}
|
|
|
|
for idx, track_idx in enumerate(checkpoints):
|
|
next_t = wave_tracks[track_idx]
|
|
is_opening = (idx == 0)
|
|
|
|
# 1. Generate DJ speech text
|
|
speech_text = await dj_host.generate_speech_text(
|
|
next_track={"artist": next_t["artist"], "title": next_t["title"]},
|
|
is_opening=is_opening
|
|
)
|
|
|
|
# 2. Synthesize audio file
|
|
segment_filename = f"{idx+1:02d}_dj_intro.mp3"
|
|
segment_path = os.path.join(self.radio_dir, segment_filename)
|
|
title = f"AI DJ: Приветствие и погода" if is_opening else f"AI DJ: Подводка к «{next_t['title']}»"
|
|
|
|
success = await dj_host.synthesize_speech(
|
|
text=speech_text,
|
|
output_file=segment_path,
|
|
title=title,
|
|
voice=voice
|
|
)
|
|
|
|
if success:
|
|
speech_map[track_idx] = segment_filename
|
|
generated_segments.append({
|
|
"segment": segment_filename,
|
|
"text": speech_text,
|
|
"before_track": f"{next_t['artist']} - {next_t['title']}"
|
|
})
|
|
print(f"[RadioManager] 🎙️ Created DJ clip {segment_filename}: \"{speech_text}\"")
|
|
|
|
# 3. Assemble interleaved M3U8 playlist
|
|
playlist_lines = ["#EXTM3U\n"]
|
|
for i, t in enumerate(wave_tracks):
|
|
# Insert DJ voice if checkpoint
|
|
if i in speech_map:
|
|
dj_file = speech_map[i]
|
|
playlist_lines.append(f"#EXTINF:-1,AI DJ - Эфирная подводка\n")
|
|
playlist_lines.append(f"AI Recommendations/Радио/{dj_file}\n")
|
|
|
|
# Music track
|
|
playlist_lines.append(f"#EXTINF:-1,{t['artist']} - {t['title']}\n")
|
|
playlist_lines.append(f"AI Recommendations/Моя Волна/{t['filename']}\n")
|
|
|
|
# Write to /music/AI Радио.m3u8
|
|
m3u_root_path = os.path.join(settings.MUSIC_DIR, "AI Радио.m3u8")
|
|
with open(m3u_root_path, "w", encoding="utf-8") as f:
|
|
f.writelines(playlist_lines)
|
|
|
|
# Write to /music/playlists/AI Радио.m3u8
|
|
playlists_dir = os.path.join(settings.MUSIC_DIR, "playlists")
|
|
os.makedirs(playlists_dir, exist_ok=True)
|
|
m3u_playlists_path = os.path.join(playlists_dir, "AI Радио.m3u8")
|
|
with open(m3u_playlists_path, "w", encoding="utf-8") as f:
|
|
f.writelines(playlist_lines)
|
|
|
|
# Trigger background library scan in Navidrome
|
|
try:
|
|
from app.core.navidrome import trigger_navidrome_scan
|
|
await trigger_navidrome_scan()
|
|
except Exception as e:
|
|
print(f"[RadioManager] Warning triggering Navidrome scan: {e}")
|
|
|
|
return {
|
|
"status": "ok",
|
|
"playlist_name": "AI Радио",
|
|
"m3u_path": m3u_root_path,
|
|
"total_music_tracks": len(wave_tracks),
|
|
"dj_segments_count": len(generated_segments),
|
|
"segments": generated_segments
|
|
}
|
|
|
|
def get_radio_status(self) -> Dict[str, Any]:
|
|
"""Get summary of current AI Radio state."""
|
|
speech_files = self.get_existing_speech_files()
|
|
m3u_path = os.path.join(settings.MUSIC_DIR, "AI Радио.m3u8")
|
|
playlist_ready = os.path.exists(m3u_path)
|
|
|
|
return {
|
|
"enabled": settings.RADIO_ENABLED,
|
|
"playlist_ready": playlist_ready,
|
|
"speech_clips_count": len(speech_files),
|
|
"speech_clips": speech_files,
|
|
"default_voice": settings.RADIO_VOICE,
|
|
"available_voices": [
|
|
{"id": "ru-RU-DmitryNeural", "name": "Дмитрий (Харизматичный DJ)", "gender": "Мужской"},
|
|
{"id": "ru-RU-SvetlanaNeural", "name": "Светлана (Мягкий вечерний голос)", "gender": "Женский"}
|
|
]
|
|
}
|
|
|
|
radio_manager = RadioManager()
|