feat: Phase 3 AI Radio (DJ Host & interleaved playlist) and Like/Dislike/Delete actions
- 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
This commit is contained in:
1 parent
3db9bdb38b
commit
e5afeb06cc
15 files changed
+945
-8
No files matched your search
@@ -26,19 +26,27 @@
|
|||||||
- **🔐 Защита & Авторизация через Navidrome**:
|
- **🔐 Защита & Авторизация через Navidrome**:
|
||||||
- Панель синхронизации защищена авторизацией. Для входа используются те же имя пользователя и пароль, что и в Navidrome.
|
- Панель синхронизации защищена авторизацией. Для входа используются те же имя пользователя и пароль, что и в Navidrome.
|
||||||
- Сессионные токены HMAC (30 дней) для браузера и поддержка HTTP Basic Auth для API / автоматизации.
|
- Сессионные токены HMAC (30 дней) для браузера и поддержка HTTP Basic Auth для API / автоматизации.
|
||||||
- **🌊 Моя Волна (Smart Wave V2)**:
|
- **🌊 Моя Волна (Smart Wave V2 & V3)**:
|
||||||
- Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`).
|
- Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`).
|
||||||
- **Контекстный LLM-реранкер**: локальная Ollama (`qwen2.5:1.5b` с `keep_alive: 0`, 0 МБ RAM в простое) анализирует расширенный пул кандидатов и отбирает треки под время суток (*утро / день / вечер / ночь*) или выбранное настроение (*Энергия*, *Чилл*, *Андеграунд*).
|
- **Контекстный LLM-реранкер**: локальная Ollama (`qwen2.5:1.5b` с `keep_alive: 0`, 0 МБ RAM в простое) анализирует расширенный пул кандидатов и отбирает треки под время суток (*утро / день / вечер / ночь*) или выбранное настроение (*Энергия*, *Чилл*, *Андеграунд*).
|
||||||
|
- **⭐ Лайк («В коллекцию»)**: моментальный перенос трека в постоянную медиатеку `/music/{Artist}/{Album}/{Title}.mp3` и мгновенное пополнение Волны свежим кандидатом.
|
||||||
|
- **👎 Дизлайк**: моментальное удаление файла и занесение исполнителя и трека в persistent черный список `disliked_tracks`, исключающий его из всех будущих рекомендаций навсегда.
|
||||||
- Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ).
|
- Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ).
|
||||||
- **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется.
|
- **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется.
|
||||||
- **Фоновое расписание**: автоматическая ротация и пополнение буфера каждые 6 часов.
|
- **Фоновое расписание**: автоматическая ротация и пополнение буфера каждые 6 часов.
|
||||||
- Автоматическая генерация умного плейлиста `Моя Волна.nsp` и `Моя Волна.m3u8` для всех Subsonic клиентов.
|
- Автоматическая генерация умного плейлиста `Моя Волна.nsp` и `Моя Волна.m3u8` для всех Subsonic клиентов.
|
||||||
|
- **🎙️ AI Радио (AI DJ Host & AI Радио.m3u8)**:
|
||||||
|
- Персональный радиоэфир: виртуальный ведущий радиостанции приветствует слушателей по времени суток, объявляет погоду за окном в реальном времени (`wttr.in`) и делает короткие живые подводки к трекам.
|
||||||
|
- Нейросетевая генерация реплик диктора через `qwen2.5:1.5b` (`keep_alive: 0`).
|
||||||
|
- Нейросинтез речи с нулевой нагрузкой на GPU и сохранением MP3-реплик с ID3-тегами `AI Радио (DJ)`.
|
||||||
|
- Отдельный интерливинг-плейлист `AI Радио.m3u8` (реплики ведущего чередуются перед музыкальными треками каждые 2–3 песни), сохраняя `Моя Волна` полностью чистой от разговоров.
|
||||||
|
- Автоматическое уведомление Navidrome через Subsonic API (`startScan`) для мгновенного появления радиоэфира в клиентах.
|
||||||
- **Интеграция с Navidrome**:
|
- **Интеграция с Navidrome**:
|
||||||
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
|
- Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`.
|
||||||
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
|
- Автоматическое скачивание построчных караоке-текстов (`.lrc`).
|
||||||
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
|
- Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки.
|
||||||
- **Веб-интерфейс & REST API**:
|
- **Веб-интерфейс & REST API**:
|
||||||
- Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны и историей загрузок.
|
- Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны, карточкой AI Радио с выбором голоса ведущего, кнопками Лайк/Дизлайк и удалением треков.
|
||||||
- Фоновый асинхронный воркер без блокировки интерфейса.
|
- Фоновый асинхронный воркер без блокировки интерфейса.
|
||||||
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
|
- Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке).
|
||||||
|
|
||||||
@@ -77,5 +85,10 @@ docker compose up -d --build
|
|||||||
- `GET /api/wave/status` — Статус буфера «Моей Волны» и якорные артисты.
|
- `GET /api/wave/status` — Статус буфера «Моей Волны» и якорные артисты.
|
||||||
- `POST /api/wave/generate` — Генерация свежих рекомендаций и наполнение буфера Волны.
|
- `POST /api/wave/generate` — Генерация свежих рекомендаций и наполнение буфера Волны.
|
||||||
- `POST /api/wave/rotate` — Ротация буфера (перенос лайкнутых в библиотеку, очистка устаревших).
|
- `POST /api/wave/rotate` — Ротация буфера (перенос лайкнутых в библиотеку, очистка устаревших).
|
||||||
|
- `POST /api/wave/like` — Лайк трека в Волне (мгновенный перенос в постоянную библиотеку и докачка нового слота).
|
||||||
|
- `POST /api/wave/dislike` — Дизлайк трека в Волне (удаление, занесение в blacklist и докачка замены).
|
||||||
|
- `DELETE /api/track?file_path={path}` — Удаление любого трека с диска и из истории.
|
||||||
|
- `GET /api/radio/status` — Статус AI Радиовещания и количество реплик ведущего.
|
||||||
|
- `POST /api/radio/generate` — Генерация реплик ведущего и сборка плейлиста «AI Радио.m3u8».
|
||||||
- `GET /api/tasks` — Список активных задач и прогресс выполнения.
|
- `GET /api/tasks` — Список активных задач и прогресс выполнения.
|
||||||
- `GET /api/history` — История и статистика загруженной музыки.
|
- `GET /api/history` — История и статистика загруженной музыки.
|
||||||
@@ -28,6 +28,12 @@ class Settings(BaseSettings):
|
|||||||
WAVE_AUTO_ROTATE_HOURS: int = 6
|
WAVE_AUTO_ROTATE_HOURS: int = 6
|
||||||
WAVE_CANDIDATE_POOL: int = 40
|
WAVE_CANDIDATE_POOL: int = 40
|
||||||
|
|
||||||
|
# AI Radio DJ Settings
|
||||||
|
RADIO_ENABLED: bool = True
|
||||||
|
RADIO_VOICE: str = "ru-RU-DmitryNeural"
|
||||||
|
WEATHER_CITY: str = ""
|
||||||
|
RADIO_INTRO_INTERVAL: int = 2
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
env_file = ".env"
|
env_file = ".env"
|
||||||
env_file_encoding = "utf-8"
|
env_file_encoding = "utf-8"
|
||||||
|
|||||||
@@ -44,6 +44,17 @@ def init_db():
|
|||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
""")
|
""")
|
||||||
|
|
||||||
|
# Disliked / Blacklisted tracks table
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS disliked_tracks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(artist, title)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -66,6 +77,42 @@ def record_downloaded_track(service: str, service_id: str, artist: str, title: s
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def remove_downloaded_track(file_path: str):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM downloaded_tracks WHERE file_path = ?", (file_path,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def add_disliked_track(artist: str, title: str):
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT OR IGNORE INTO disliked_tracks (artist, title)
|
||||||
|
VALUES (?, ?)
|
||||||
|
""", (artist.strip(), title.strip()))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_disliked_track_keys() -> set:
|
||||||
|
import re
|
||||||
|
def _norm(s: str) -> str:
|
||||||
|
s = s.lower()
|
||||||
|
s = re.sub(r"[^\w\s]", "", s)
|
||||||
|
return " ".join(s.split())
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT artist, title FROM disliked_tracks")
|
||||||
|
keys = set()
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
art = _norm(r[0])
|
||||||
|
tit = _norm(r[1])
|
||||||
|
if art and tit:
|
||||||
|
keys.add(f"{art}|{tit}")
|
||||||
|
conn.close()
|
||||||
|
return keys
|
||||||
|
|
||||||
def get_recent_tracks(limit: int = 50) -> List[Dict[str, Any]]:
|
def get_recent_tracks(limit: int = 50) -> List[Dict[str, Any]]:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|||||||
+66
-2
@@ -9,8 +9,8 @@ from pydantic import BaseModel
|
|||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import init_db, get_recent_tracks, get_tracks_count, get_db
|
from app.db import init_db, get_recent_tracks, get_tracks_count, get_db, remove_downloaded_track
|
||||||
from app.queue import sync_manager
|
from app.sync_queue import sync_manager
|
||||||
from app.core.search import search_all
|
from app.core.search import search_all
|
||||||
from app.core.tagger import get_track_dest_path
|
from app.core.tagger import get_track_dest_path
|
||||||
from app.core.auth import (
|
from app.core.auth import (
|
||||||
@@ -22,6 +22,7 @@ from app.core.auth import (
|
|||||||
)
|
)
|
||||||
from app.wave.rotator import wave_rotator
|
from app.wave.rotator import wave_rotator
|
||||||
from app.wave.playlist import ensure_smart_playlist
|
from app.wave.playlist import ensure_smart_playlist
|
||||||
|
from app.radio import radio_manager
|
||||||
|
|
||||||
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
|
app = FastAPI(title="Navidrome Music Sync", version="1.0.0")
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ class DownloadRequest(BaseModel):
|
|||||||
class WaveGenerateRequest(BaseModel):
|
class WaveGenerateRequest(BaseModel):
|
||||||
mood: Optional[str] = "auto"
|
mood: Optional[str] = "auto"
|
||||||
|
|
||||||
|
class WaveActionRequest(BaseModel):
|
||||||
|
filename: str
|
||||||
|
|
||||||
|
class RadioGenerateRequest(BaseModel):
|
||||||
|
voice: Optional[str] = "ru-RU-DmitryNeural"
|
||||||
|
|
||||||
async def _wave_auto_rotation_loop():
|
async def _wave_auto_rotation_loop():
|
||||||
"""Background loop to periodically rotate and refill Smart Wave buffer."""
|
"""Background loop to periodically rotate and refill Smart Wave buffer."""
|
||||||
while True:
|
while True:
|
||||||
@@ -270,3 +277,60 @@ def rotate_wave(user: str = Depends(require_auth)):
|
|||||||
"purged_tracks": purged,
|
"purged_tracks": purged,
|
||||||
"current_status": wave_rotator.get_status()
|
"current_status": wave_rotator.get_status()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@app.post("/api/wave/dislike")
|
||||||
|
async def dislike_wave_track(req: WaveActionRequest, user: str = Depends(require_auth)):
|
||||||
|
"""Dislike track: remove from wave, blacklist from recommendations, refill slot."""
|
||||||
|
try:
|
||||||
|
res = await wave_rotator.dislike_track(req.filename)
|
||||||
|
return res
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Трек не найден в буфере Волны")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.post("/api/wave/like")
|
||||||
|
async def like_wave_track(req: WaveActionRequest, user: str = Depends(require_auth)):
|
||||||
|
"""Like track: promote to permanent library, refill slot."""
|
||||||
|
try:
|
||||||
|
res = await wave_rotator.like_track(req.filename)
|
||||||
|
return res
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail="Трек не найден в буфере Волны")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
@app.delete("/api/track")
|
||||||
|
def delete_track(file_path: str = Query(...), user: str = Depends(require_auth)):
|
||||||
|
"""Delete any track from library/disk and remove from download history."""
|
||||||
|
real_path = os.path.realpath(file_path)
|
||||||
|
music_real = os.path.realpath(settings.MUSIC_DIR)
|
||||||
|
if not real_path.startswith(music_real):
|
||||||
|
raise HTTPException(status_code=400, detail="Недопустимый путь к файлу")
|
||||||
|
if not os.path.exists(real_path):
|
||||||
|
raise HTTPException(status_code=404, detail="Файл трека не найден на диске")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(real_path)
|
||||||
|
lrc_path = os.path.splitext(real_path)[0] + ".lrc"
|
||||||
|
if os.path.exists(lrc_path):
|
||||||
|
os.remove(lrc_path)
|
||||||
|
remove_downloaded_track(file_path)
|
||||||
|
ensure_smart_playlist()
|
||||||
|
return {"status": "deleted", "file_path": file_path}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Ошибка удаления: {e}")
|
||||||
|
|
||||||
|
# --- AI Radio DJ Endpoints (Phase 3) ---
|
||||||
|
@app.get("/api/radio/status")
|
||||||
|
def get_radio_status(user: str = Depends(require_auth)):
|
||||||
|
"""Return status of AI Radio host and broadcast playlist."""
|
||||||
|
return radio_manager.get_radio_status()
|
||||||
|
|
||||||
|
@app.post("/api/radio/generate")
|
||||||
|
async def generate_radio_broadcast(req: Optional[RadioGenerateRequest] = None, user: str = Depends(require_auth)):
|
||||||
|
"""Generate AI DJ speech intros and assemble the interleaved 'AI Радио.m3u8' playlist."""
|
||||||
|
voice = req.voice if req else settings.RADIO_VOICE
|
||||||
|
res = await radio_manager.generate_radio_broadcast(voice=voice)
|
||||||
|
return res
|
||||||
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from app.radio.manager import radio_manager
|
||||||
|
from app.radio.dj import dj_host
|
||||||
|
from app.radio.weather import get_current_weather
|
||||||
|
|
||||||
|
__all__ = ["radio_manager", "dj_host", "get_current_weather"]
|
||||||
+141
@@ -0,0 +1,141 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import httpx
|
||||||
|
import asyncio
|
||||||
|
import edge_tts
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from mutagen.id3 import ID3, TIT2, TPE1, TALB, ID3NoHeaderError
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.radio.weather import get_current_weather
|
||||||
|
|
||||||
|
class RadioDJ:
|
||||||
|
def __init__(self):
|
||||||
|
self.ollama_url = f"{settings.OLLAMA_URL}/api/generate"
|
||||||
|
self.model = settings.OLLAMA_MODEL
|
||||||
|
self.default_voice = settings.RADIO_VOICE or "ru-RU-DmitryNeural"
|
||||||
|
|
||||||
|
def get_time_of_day_info(self) -> Dict[str, str]:
|
||||||
|
"""Determine time of day greeting."""
|
||||||
|
hour = (datetime.utcnow().hour + 3) % 24 # Europe/Moscow UTC+3 approximation
|
||||||
|
if 5 <= hour < 12:
|
||||||
|
return {"greeting": "Доброе утро", "period": "утренний эфир"}
|
||||||
|
elif 12 <= hour < 18:
|
||||||
|
return {"greeting": "Добрый день", "period": "дневной эфир"}
|
||||||
|
elif 18 <= hour < 23:
|
||||||
|
return {"greeting": "Добрый вечер", "period": "вечерний эфир"}
|
||||||
|
else:
|
||||||
|
return {"greeting": "Доброй ночи", "period": "ночной эфир"}
|
||||||
|
|
||||||
|
async def generate_speech_text(
|
||||||
|
self,
|
||||||
|
next_track: Optional[Dict[str, str]] = None,
|
||||||
|
is_opening: bool = False
|
||||||
|
) -> str:
|
||||||
|
"""Generate charismatic short radio host remark via Ollama qwen2.5:1.5b."""
|
||||||
|
tod = self.get_time_of_day_info()
|
||||||
|
weather = get_current_weather()
|
||||||
|
|
||||||
|
weather_part = f"Погода: {weather}." if weather else ""
|
||||||
|
track_part = f"Следующий трек: «{next_track['title']}» исполнителя {next_track['artist']}." if next_track else ""
|
||||||
|
|
||||||
|
if is_opening:
|
||||||
|
system_instruction = (
|
||||||
|
f"Ты харизматичный ведущий радиостанции «AI Радио». "
|
||||||
|
f"Сейчас {tod['period']}, {tod['greeting']}. {weather_part} "
|
||||||
|
f"Сделай открывающее приветствие радиослушателям (ровно 2 коротких предложения, до 25 слов) "
|
||||||
|
f"и объяви первый трек: {track_part}. "
|
||||||
|
f"Никаких приветствий от лица нейросети, никаких кавычек, ремарок в скобках или мета-текста. "
|
||||||
|
f"Только чистая живая речь радиоведущего на русском языке."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
system_instruction = (
|
||||||
|
f"Ты классный ведущий радиостанции «AI Радио». "
|
||||||
|
f"Сейчас в эфире {tod['period']}. "
|
||||||
|
f"Сделай лаконичную подводку ведущего (1-2 предложения, до 20 слов) перед следующим треком: {track_part}. "
|
||||||
|
f"Без кавычек, без смайлов, без скобок, только чистый текст ведущего на русском."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Call Ollama with keep_alive: 0
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=25.0) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
self.ollama_url,
|
||||||
|
json={
|
||||||
|
"model": self.model,
|
||||||
|
"prompt": system_instruction,
|
||||||
|
"stream": False,
|
||||||
|
"keep_alive": 0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
text = resp.json().get("response", "").strip()
|
||||||
|
# Clean up quotes, brackets, preamble
|
||||||
|
text = text.replace('"', '').replace('«', '').replace('»', '').replace('*', '')
|
||||||
|
lines = [l.strip() for l in text.split("\n") if l.strip()]
|
||||||
|
if lines:
|
||||||
|
cleaned = " ".join(lines)
|
||||||
|
if len(cleaned) > 10:
|
||||||
|
return cleaned
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[RadioDJ] Ollama generation fallback due to: {e}")
|
||||||
|
|
||||||
|
# Deterministic fallback if Ollama is unavailable
|
||||||
|
if is_opening:
|
||||||
|
w_str = f" За окном {weather}." if weather else ""
|
||||||
|
t_str = f" Открываем эфир треком {next_track['artist']} — {next_track['title']}!" if next_track else " Начинаем наш музыкальный эфир!"
|
||||||
|
return f"{tod['greeting']} в эфире AI Радио!{w_str}{t_str} Поехали!"
|
||||||
|
else:
|
||||||
|
if next_track:
|
||||||
|
return f"Продолжаем поток на AI Радио. В эфире звучит {next_track['artist']} — {next_track['title']}."
|
||||||
|
return "Вы слушаете AI Радио — только лучшая музыка для отличного настроения."
|
||||||
|
|
||||||
|
def _apply_id3_tags(self, output_file: str, title: str):
|
||||||
|
"""Write ID3 tags so Navidrome categorizes host clips as AI Radio tracks."""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
tags = ID3(output_file)
|
||||||
|
except ID3NoHeaderError:
|
||||||
|
tags = ID3()
|
||||||
|
tags.add(TPE1(encoding=3, text=["AI Радио (DJ)"]))
|
||||||
|
tags.add(TALB(encoding=3, text=["AI Радио Эфир"]))
|
||||||
|
tags.add(TIT2(encoding=3, text=[title]))
|
||||||
|
tags.save(output_file)
|
||||||
|
except Exception as tag_err:
|
||||||
|
print(f"[RadioDJ] Warning tagging audio: {tag_err}")
|
||||||
|
|
||||||
|
async def synthesize_speech(
|
||||||
|
self,
|
||||||
|
text: str,
|
||||||
|
output_file: str,
|
||||||
|
title: str = "Ведущий в эфире",
|
||||||
|
voice: Optional[str] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Synthesize speech using edge-tts or rock-solid gTTS fallback and write ID3 tags for Navidrome."""
|
||||||
|
selected_voice = voice or self.default_voice
|
||||||
|
|
||||||
|
# 1. Attempt edge-tts
|
||||||
|
try:
|
||||||
|
tts = edge_tts.Communicate(text, selected_voice)
|
||||||
|
await tts.save(output_file)
|
||||||
|
if os.path.exists(output_file) and os.path.getsize(output_file) > 500:
|
||||||
|
self._apply_id3_tags(output_file, title)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[RadioDJ] edge-tts error ({e}), trying gTTS fallback...")
|
||||||
|
|
||||||
|
# 2. Resilient fallback to gTTS
|
||||||
|
try:
|
||||||
|
from gtts import gTTS
|
||||||
|
tts = gTTS(text=text, lang="ru")
|
||||||
|
tts.save(output_file)
|
||||||
|
if os.path.exists(output_file) and os.path.getsize(output_file) > 500:
|
||||||
|
self._apply_id3_tags(output_file, title)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[RadioDJ] gTTS synthesis error: {e}")
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
dj_host = RadioDJ()
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import urllib.request
|
||||||
|
import urllib.parse
|
||||||
|
from typing import Optional
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
def get_current_weather() -> Optional[str]:
|
||||||
|
"""Fetch current weather report from wttr.in in Russian."""
|
||||||
|
city = urllib.parse.quote(settings.WEATHER_CITY) if settings.WEATHER_CITY else ""
|
||||||
|
url = f"https://wttr.in/{city}?format=%C,+%t&lang=ru"
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(
|
||||||
|
url,
|
||||||
|
headers={"User-Agent": "curl/7.88.1"}
|
||||||
|
)
|
||||||
|
with urllib.request.urlopen(req, timeout=3.5) as resp:
|
||||||
|
text = resp.read().decode("utf-8").strip()
|
||||||
|
# Basic validation
|
||||||
|
if text and not text.startswith("<") and len(text) < 60:
|
||||||
|
return text
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[RadioWeather] Warning: unable to fetch weather: {e}")
|
||||||
|
return None
|
||||||
+152
-2
@@ -267,7 +267,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
function renderHistory(tracks) {
|
function renderHistory(tracks) {
|
||||||
if (!tracks || tracks.length === 0) {
|
if (!tracks || tracks.length === 0) {
|
||||||
historyBody.innerHTML = `<tr><td colspan="5" class="empty-state">Пока нет загрузок</td></tr>`;
|
historyBody.innerHTML = `<tr><td colspan="6" class="empty-state">Пока нет загрузок</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,6 +278,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
<td>${escapeHtml(t.title)}</td>
|
<td>${escapeHtml(t.title)}</td>
|
||||||
<td>${escapeHtml(t.album || 'Singles')}</td>
|
<td>${escapeHtml(t.album || 'Singles')}</td>
|
||||||
<td style="color: var(--text-secondary);">${formatDate(t.downloaded_at)}</td>
|
<td style="color: var(--text-secondary);">${formatDate(t.downloaded_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn-delete" title="Удалить трек из Navidrome" onclick="deleteHistoryTrack('${escapeAttr(t.file_path)}')">🗑️</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
@@ -413,14 +416,161 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад)
|
${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад)
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div style="display: flex; gap: 0.4rem; flex-shrink: 0;">
|
<div style="display: flex; gap: 0.4rem; flex-shrink: 0; align-items: center;">
|
||||||
<button class="btn-play-preview" onclick="playPreviewTrack('${escapeAttr(t.path)}', '${escapeAttr(t.title)}', '${escapeAttr(t.artist)}', '', '', 'api/stream?url=' + encodeURIComponent('${escapeAttr(t.path)}'))">
|
<button class="btn-play-preview" onclick="playPreviewTrack('${escapeAttr(t.path)}', '${escapeAttr(t.title)}', '${escapeAttr(t.artist)}', '', '', 'api/stream?url=' + encodeURIComponent('${escapeAttr(t.path)}'))">
|
||||||
▶ Слушать
|
▶ Слушать
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-like" title="Сохранить в библиотеку Navidrome" onclick="likeWaveTrack('${escapeAttr(t.filename)}')">
|
||||||
|
⭐ В коллекцию
|
||||||
|
</button>
|
||||||
|
<button class="btn-dislike" title="Удалить из волны и больше не рекомендовать" onclick="dislikeWaveTrack('${escapeAttr(t.filename)}')">
|
||||||
|
👎 Дизлайк
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join("");
|
`).join("");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Window actions for Like, Dislike & Delete
|
||||||
|
window.likeWaveTrack = async function(filename) {
|
||||||
|
if (!filename) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("api/wave/like", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename: filename })
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
alert(`⭐ Трек [${data.artist} — ${data.title}] перенесён в вашу постоянную библиотеку Navidrome!\nСлот в Волне автоматически обновлен.`);
|
||||||
|
await fetchWaveStatus();
|
||||||
|
await fetchHistory();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ошибка добавления в коллекцию: " + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dislikeWaveTrack = async function(filename) {
|
||||||
|
if (!filename) return;
|
||||||
|
if (!confirm("Дизлайкнуть трек? Он будет удалён из Волны и занесён в чёрный список рекомендаций.")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch("api/wave/dislike", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ filename: filename })
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
await fetchWaveStatus();
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ошибка дизлайка: " + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.deleteHistoryTrack = async function(filePath) {
|
||||||
|
if (!filePath) return;
|
||||||
|
if (!confirm("Удалить этот трек из библиотеки и стереть файл с диска?")) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`api/track?file_path=${encodeURIComponent(filePath)}`, {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (res.ok) {
|
||||||
|
await fetchHistory();
|
||||||
|
await fetchWaveStatus();
|
||||||
|
} else {
|
||||||
|
const err = await res.json();
|
||||||
|
alert("Ошибка удаления: " + (err.detail || res.statusText));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert("Ошибка сети при удалении: " + e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- AI Radio Host Controller (Phase 3) ---
|
||||||
|
const radioGenerateBtn = document.getElementById("radioGenerateBtn");
|
||||||
|
const radioVoiceSelect = document.getElementById("radioVoiceSelect");
|
||||||
|
const radioStatusLabel = document.getElementById("radioStatusLabel");
|
||||||
|
const radioClipsCountLabel = document.getElementById("radioClipsCountLabel");
|
||||||
|
const radioSegmentsContainer = document.getElementById("radioSegmentsContainer");
|
||||||
|
const radioSegmentsList = document.getElementById("radioSegmentsList");
|
||||||
|
|
||||||
|
fetchRadioStatus();
|
||||||
|
|
||||||
|
if (radioGenerateBtn) {
|
||||||
|
radioGenerateBtn.addEventListener("click", async () => {
|
||||||
|
radioGenerateBtn.disabled = true;
|
||||||
|
const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru-RU-DmitryNeural";
|
||||||
|
if (radioStatusLabel) {
|
||||||
|
radioStatusLabel.textContent = "🎙️ Ведущий пишет реплики и сводит эфир...";
|
||||||
|
radioStatusLabel.className = "badge badge-llm";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("api/radio/generate", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ voice: voice })
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.status === "ok") {
|
||||||
|
alert(`📻 Радиоэфир успешно сформирован!\nСгенерировано реплик ведущего: ${data.dj_segments_count}\nПлейлист «AI Радио.m3u8» готов в Navidrome.`);
|
||||||
|
if (data.segments && data.segments.length > 0 && radioSegmentsContainer && radioSegmentsList) {
|
||||||
|
radioSegmentsContainer.style.display = "block";
|
||||||
|
radioSegmentsList.innerHTML = data.segments.map(s => `
|
||||||
|
<div class="radio-segment-item">
|
||||||
|
<div style="min-width: 0;">
|
||||||
|
<div class="radio-segment-text">«${escapeHtml(s.text)}»</div>
|
||||||
|
<div class="radio-segment-target">🎵 Перед треком: ${escapeHtml(s.before_track)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
alert("Внимание: " + (data.message || "Ошибка генерации"));
|
||||||
|
}
|
||||||
|
await fetchRadioStatus();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Ошибка генерации радиоэфира: " + err);
|
||||||
|
} finally {
|
||||||
|
radioGenerateBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchRadioStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch("api/radio/status");
|
||||||
|
if (res.status === 401) return;
|
||||||
|
const data = await res.json();
|
||||||
|
if (radioClipsCountLabel) {
|
||||||
|
radioClipsCountLabel.textContent = `${data.speech_clips_count || 0} записей`;
|
||||||
|
}
|
||||||
|
if (radioStatusLabel) {
|
||||||
|
if (data.playlist_ready && data.speech_clips_count > 0) {
|
||||||
|
radioStatusLabel.textContent = "🟢 Эфир в ротации";
|
||||||
|
radioStatusLabel.className = "badge badge-success";
|
||||||
|
} else {
|
||||||
|
radioStatusLabel.textContent = "🟡 Ожидает генерации";
|
||||||
|
radioStatusLabel.className = "badge badge-warning";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Radio status error:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
@@ -811,3 +811,182 @@ input[type="text"]:focus {
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 3: Like, Dislike & AI Radio Styles */
|
||||||
|
.btn-like {
|
||||||
|
background-color: rgba(16, 185, 129, 0.15);
|
||||||
|
color: #34d399;
|
||||||
|
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-like:hover {
|
||||||
|
background-color: rgba(16, 185, 129, 0.3);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dislike {
|
||||||
|
background-color: rgba(239, 68, 68, 0.12);
|
||||||
|
color: #f87171;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.25);
|
||||||
|
padding: 0.35rem 0.65rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-dislike:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.28);
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background-color: transparent;
|
||||||
|
color: #ef4444;
|
||||||
|
border: 1px solid rgba(239, 68, 68, 0.2);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background-color: rgba(239, 68, 68, 0.2);
|
||||||
|
border-color: rgba(239, 68, 68, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-card {
|
||||||
|
border-left: 4px solid #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-radio {
|
||||||
|
background-color: rgba(245, 158, 11, 0.18);
|
||||||
|
color: #fbbf24;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-header-title h2 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-subtitle {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-settings-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin: 0.8rem 0;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-voice-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-voice-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-voice-select {
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.35rem 0.7rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-voice-select:focus {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-stats-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
background-color: rgba(15, 23, 42, 0.5);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-top: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-stat a {
|
||||||
|
color: #fbbf24;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-stat a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-segments-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-segment-item {
|
||||||
|
background-color: rgba(15, 23, 42, 0.6);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-segment-text {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio-segment-target {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #fbbf24;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
File renamed without changes.
@@ -143,6 +143,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- AI Radio Card (Phase 3) -->
|
||||||
|
<section class="card radio-card">
|
||||||
|
<div class="radio-header">
|
||||||
|
<div class="radio-header-title">
|
||||||
|
<h2>🎙️ AI Радио <span class="badge badge-radio">AI DJ Эфир</span> <span class="badge badge-llm">🤖 Qwen 2.5 + Edge-TTS</span></h2>
|
||||||
|
<p class="radio-subtitle">
|
||||||
|
Персональный радиоэфир: AI-ведущий объявляет погоду, время суток и делает динамичные подводки перед песнями в отдельном плейлисте «AI Радио».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="radio-controls">
|
||||||
|
<button id="radioGenerateBtn" class="btn btn-primary" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);">🎙️ Сгенерировать радиоэфир</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Radio Host Voice & Context Row -->
|
||||||
|
<div class="radio-settings-row">
|
||||||
|
<div class="radio-voice-group">
|
||||||
|
<label for="radioVoiceSelect" class="radio-voice-label">Голос ведущего:</label>
|
||||||
|
<select id="radioVoiceSelect" class="radio-voice-select">
|
||||||
|
<option value="ru-RU-DmitryNeural" selected>🎙️ Дмитрий (Харизматичный)</option>
|
||||||
|
<option value="ru-RU-SvetlanaNeural">🎙️ Светлана (Мягкий)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="radio-status-badge-wrap">
|
||||||
|
<span id="radioStatusLabel" class="badge badge-context">Эфир готов к запуску</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="radio-stats-row">
|
||||||
|
<div class="radio-stat">
|
||||||
|
<span class="radio-stat-label">Реплик ведущего:</span>
|
||||||
|
<strong id="radioClipsCountLabel">0 записей</strong>
|
||||||
|
</div>
|
||||||
|
<div class="radio-stat">
|
||||||
|
<span class="radio-stat-label">Эфирный плейлист:</span>
|
||||||
|
<strong><a href="https://music.qpid.pro" target="_blank">AI Радио.m3u8 ↗</a></strong>
|
||||||
|
</div>
|
||||||
|
<div class="radio-stat">
|
||||||
|
<span class="radio-stat-label">Чистая музыка (без ведущего):</span>
|
||||||
|
<strong><a href="https://music.qpid.pro" target="_blank">Моя Волна.nsp ↗</a></strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="radioSegmentsContainer" class="radio-segments-container" style="margin-top: 1rem; display: none;">
|
||||||
|
<h3 style="font-size: 0.95rem; margin-bottom: 0.4rem;">📻 Реплики ведущего в текущем радиоэфире:</h3>
|
||||||
|
<div id="radioSegmentsList" class="radio-segments-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<!-- Active Tasks -->
|
<!-- Active Tasks -->
|
||||||
<section class="card tasks-card">
|
<section class="card tasks-card">
|
||||||
<h2>⚡ Очередь задач</h2>
|
<h2>⚡ Очередь задач</h2>
|
||||||
@@ -163,11 +212,12 @@
|
|||||||
<th>Трек</th>
|
<th>Трек</th>
|
||||||
<th>Альбом</th>
|
<th>Альбом</th>
|
||||||
<th>Дата</th>
|
<th>Дата</th>
|
||||||
|
<th>Действия</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="historyBody">
|
<tbody id="historyBody">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" class="empty-state">Пока нет загрузок</td>
|
<td colspan="6" class="empty-state">Пока нет загрузок</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -124,8 +124,16 @@ class WaveCollector:
|
|||||||
return profile
|
return profile
|
||||||
|
|
||||||
def get_existing_track_keys(self) -> Set[str]:
|
def get_existing_track_keys(self) -> Set[str]:
|
||||||
"""Returns set of normalized 'artist|title' of all songs already in Navidrome library."""
|
"""Returns set of normalized 'artist|title' of all songs already in Navidrome library and disliked tracks."""
|
||||||
keys: Set[str] = set()
|
keys: Set[str] = set()
|
||||||
|
|
||||||
|
# Exclude disliked / blacklisted tracks
|
||||||
|
try:
|
||||||
|
from app.db import get_disliked_track_keys
|
||||||
|
keys.update(get_disliked_track_keys())
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WaveCollector] Warning reading disliked keys: {e}")
|
||||||
|
|
||||||
conn = self._get_connection()
|
conn = self._get_connection()
|
||||||
if not conn:
|
if not conn:
|
||||||
return keys
|
return keys
|
||||||
|
|||||||
@@ -227,6 +227,100 @@ class WaveRotator:
|
|||||||
"purged_tracks": purged
|
"purged_tracks": purged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async def dislike_track(self, filename: str) -> Dict[str, Any]:
|
||||||
|
"""User dislikes a track: delete it from Wave, add to blacklisted tracks, refill buffer."""
|
||||||
|
fpath = os.path.join(self.wave_dir, os.path.basename(filename))
|
||||||
|
if not os.path.exists(fpath):
|
||||||
|
raise FileNotFoundError(f"Track not found in wave: {filename}")
|
||||||
|
|
||||||
|
name_no_ext = os.path.splitext(os.path.basename(filename))[0]
|
||||||
|
artist = "Unknown"
|
||||||
|
title = name_no_ext
|
||||||
|
if " - " in name_no_ext:
|
||||||
|
parts = name_no_ext.split(" - ", 1)
|
||||||
|
artist = parts[0].strip()
|
||||||
|
title = parts[1].strip()
|
||||||
|
|
||||||
|
from app.db import add_disliked_track
|
||||||
|
add_disliked_track(artist, title)
|
||||||
|
print(f"[WaveRotator] 👎 Disliked track: {artist} - {title}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
os.remove(fpath)
|
||||||
|
lrc = os.path.splitext(fpath)[0] + ".lrc"
|
||||||
|
if os.path.exists(lrc):
|
||||||
|
os.remove(lrc)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[WaveRotator] Error removing disliked track {filename}: {e}")
|
||||||
|
|
||||||
|
# Refill buffer by 1 track
|
||||||
|
await self.fill_buffer(refresh_count=0)
|
||||||
|
ensure_smart_playlist()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "disliked",
|
||||||
|
"artist": artist,
|
||||||
|
"title": title,
|
||||||
|
"buffer_status": self.get_status()
|
||||||
|
}
|
||||||
|
|
||||||
|
async def like_track(self, filename: str) -> Dict[str, Any]:
|
||||||
|
"""User likes a track: promote it immediately to permanent library, refill buffer."""
|
||||||
|
fpath = os.path.join(self.wave_dir, os.path.basename(filename))
|
||||||
|
if not os.path.exists(fpath):
|
||||||
|
raise FileNotFoundError(f"Track not found in wave: {filename}")
|
||||||
|
|
||||||
|
name_no_ext = os.path.splitext(os.path.basename(filename))[0]
|
||||||
|
artist = "Unknown"
|
||||||
|
title = name_no_ext
|
||||||
|
album = "Singles"
|
||||||
|
if " - " in name_no_ext:
|
||||||
|
parts = name_no_ext.split(" - ", 1)
|
||||||
|
artist = parts[0].strip()
|
||||||
|
title = parts[1].strip()
|
||||||
|
|
||||||
|
dest_path = get_track_dest_path(
|
||||||
|
settings.MUSIC_DIR,
|
||||||
|
artist=artist,
|
||||||
|
album=album,
|
||||||
|
track_num=None,
|
||||||
|
title=title,
|
||||||
|
ext="mp3"
|
||||||
|
)
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
|
||||||
|
shutil.move(fpath, dest_path)
|
||||||
|
|
||||||
|
# Move .lrc if exists
|
||||||
|
src_lrc = os.path.splitext(fpath)[0] + ".lrc"
|
||||||
|
if os.path.exists(src_lrc):
|
||||||
|
dest_lrc = os.path.splitext(dest_path)[0] + ".lrc"
|
||||||
|
shutil.move(src_lrc, dest_lrc)
|
||||||
|
|
||||||
|
# Record in downloaded_tracks
|
||||||
|
from app.db import record_downloaded_track
|
||||||
|
record_downloaded_track(
|
||||||
|
service="wave_liked",
|
||||||
|
service_id=filename,
|
||||||
|
artist=artist,
|
||||||
|
title=title,
|
||||||
|
album=album,
|
||||||
|
file_path=dest_path
|
||||||
|
)
|
||||||
|
print(f"[WaveRotator] ⭐ Liked and promoted to library: {artist} - {title} -> {dest_path}")
|
||||||
|
|
||||||
|
# Refill buffer by 1 track
|
||||||
|
await self.fill_buffer(refresh_count=0)
|
||||||
|
ensure_smart_playlist()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "liked",
|
||||||
|
"artist": artist,
|
||||||
|
"title": title,
|
||||||
|
"dest_path": dest_path,
|
||||||
|
"buffer_status": self.get_status()
|
||||||
|
}
|
||||||
|
|
||||||
def get_status(self) -> Dict[str, Any]:
|
def get_status(self) -> Dict[str, Any]:
|
||||||
"""Get summary status of Smart Wave."""
|
"""Get summary status of Smart Wave."""
|
||||||
buffer_tracks = self.get_current_buffer()
|
buffer_tracks = self.get_current_buffer()
|
||||||
|
|||||||
@@ -11,3 +11,5 @@ httpx>=0.27.0
|
|||||||
jinja2>=3.1.0
|
jinja2>=3.1.0
|
||||||
python-multipart>=0.0.12
|
python-multipart>=0.0.12
|
||||||
spotifyscraper>=3.9.0
|
spotifyscraper>=3.9.0
|
||||||
|
edge-tts>=6.1.12
|
||||||
|
gtts>=2.5.0
|
||||||
Reference in new issue
Block a user