From e5afeb06ccaf2652b1fe3c61765cc8d45883aee8 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 12 Oct 2026 16:34:46 +0300 Subject: [PATCH] feat: Phase 3 AI Radio (DJ Host & interleaved playlist) and Like/Dislike/Delete actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 17 ++- app/config.py | 6 ++ app/db.py | 47 +++++++++ app/main.py | 68 +++++++++++- app/radio/__init__.py | 5 + app/radio/dj.py | 141 +++++++++++++++++++++++++ app/radio/manager.py | 156 ++++++++++++++++++++++++++++ app/radio/weather.py | 22 ++++ app/static/app.js | 154 ++++++++++++++++++++++++++- app/static/style.css | 179 ++++++++++++++++++++++++++++++++ app/{queue.py => sync_queue.py} | 0 app/templates/index.html | 52 +++++++++- app/wave/collector.py | 10 +- app/wave/rotator.py | 94 +++++++++++++++++ requirements.txt | 2 + 15 files changed, 945 insertions(+), 8 deletions(-) create mode 100644 app/radio/__init__.py create mode 100644 app/radio/dj.py create mode 100644 app/radio/manager.py create mode 100644 app/radio/weather.py rename app/{queue.py => sync_queue.py} (100%) diff --git a/README.md b/README.md index 43fd082..dc4510d 100644 --- a/README.md +++ b/README.md @@ -26,19 +26,27 @@ - **🔐 Защита & Авторизация через Navidrome**: - Панель синхронизации защищена авторизацией. Для входа используются те же имя пользователя и пароль, что и в Navidrome. - Сессионные токены HMAC (30 дней) для браузера и поддержка HTTP Basic Auth для API / автоматизации. -- **🌊 Моя Волна (Smart Wave V2)**: +- **🌊 Моя Волна (Smart Wave V2 & V3)**: - Автономный генератор персональных рекомендаций на основе профиля вкусов (звёздочки, скробблы, топ артистов из `navidrome.db`). - **Контекстный LLM-реранкер**: локальная Ollama (`qwen2.5:1.5b` с `keep_alive: 0`, 0 МБ RAM в простое) анализирует расширенный пул кандидатов и отбирает треки под время суток (*утро / день / вечер / ночь*) или выбранное настроение (*Энергия*, *Чилл*, *Андеграунд*). + - **⭐ Лайк («В коллекцию»)**: моментальный перенос трека в постоянную медиатеку `/music/{Artist}/{Album}/{Title}.mp3` и мгновенное пополнение Волны свежим кандидатом. + - **👎 Дизлайк**: моментальное удаление файла и занесение исполнителя и трека в persistent черный список `disliked_tracks`, исключающий его из всех будущих рекомендаций навсегда. - Скользящий буфер на 25 треков в `/music/AI Recommendations/Моя Волна/` (~180 МБ). - **Авто-ротация**: если трек лайкнут в Navidrome — переносится в основную библиотеку навсегда; если лежит более 7 дней без лайка — автоматически удаляется. - **Фоновое расписание**: автоматическая ротация и пополнение буфера каждые 6 часов. - Автоматическая генерация умного плейлиста `Моя Волна.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**: - Организация каталога по стандарту: `/music/{Artist}/{Album}/{Track_Number} - {Title}.mp3`. - Автоматическое скачивание построчных караоке-текстов (`.lrc`). - Уведомление Navidrome через Subsonic API (`startScan`) сразу после завершения загрузки. - **Веб-интерфейс & REST API**: - - Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны и историей загрузок. + - Адаптивный темный Web UI с прогресс-баром задач, переключателем настроения Волны, карточкой AI Радио с выбором голоса ведущего, кнопками Лайк/Дизлайк и удалением треков. - Фоновый асинхронный воркер без блокировки интерфейса. - Дедупликация (не скачивает повторно треки, которые уже есть в библиотеке). @@ -77,5 +85,10 @@ docker compose up -d --build - `GET /api/wave/status` — Статус буфера «Моей Волны» и якорные артисты. - `POST /api/wave/generate` — Генерация свежих рекомендаций и наполнение буфера Волны. - `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/history` — История и статистика загруженной музыки. \ No newline at end of file diff --git a/app/config.py b/app/config.py index f551b9a..f7010da 100644 --- a/app/config.py +++ b/app/config.py @@ -28,6 +28,12 @@ class Settings(BaseSettings): WAVE_AUTO_ROTATE_HOURS: int = 6 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: env_file = ".env" env_file_encoding = "utf-8" diff --git a/app/db.py b/app/db.py index 2350830..45e33c1 100644 --- a/app/db.py +++ b/app/db.py @@ -44,6 +44,17 @@ def init_db(): 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.close() @@ -66,6 +77,42 @@ def record_downloaded_track(service: str, service_id: str, artist: str, title: s conn.commit() 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]]: conn = get_db() cursor = conn.cursor() diff --git a/app/main.py b/app/main.py index 400e2f6..cbfb22b 100644 --- a/app/main.py +++ b/app/main.py @@ -9,8 +9,8 @@ from pydantic import BaseModel from typing import List, Dict, Any, Optional from app.config import settings -from app.db import init_db, get_recent_tracks, get_tracks_count, get_db -from app.queue import sync_manager +from app.db import init_db, get_recent_tracks, get_tracks_count, get_db, remove_downloaded_track +from app.sync_queue import sync_manager from app.core.search import search_all from app.core.tagger import get_track_dest_path from app.core.auth import ( @@ -22,6 +22,7 @@ from app.core.auth import ( ) from app.wave.rotator import wave_rotator from app.wave.playlist import ensure_smart_playlist +from app.radio import radio_manager app = FastAPI(title="Navidrome Music Sync", version="1.0.0") @@ -36,6 +37,12 @@ class DownloadRequest(BaseModel): class WaveGenerateRequest(BaseModel): mood: Optional[str] = "auto" +class WaveActionRequest(BaseModel): + filename: str + +class RadioGenerateRequest(BaseModel): + voice: Optional[str] = "ru-RU-DmitryNeural" + async def _wave_auto_rotation_loop(): """Background loop to periodically rotate and refill Smart Wave buffer.""" while True: @@ -270,3 +277,60 @@ def rotate_wave(user: str = Depends(require_auth)): "purged_tracks": purged, "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 + diff --git a/app/radio/__init__.py b/app/radio/__init__.py new file mode 100644 index 0000000..fbf663a --- /dev/null +++ b/app/radio/__init__.py @@ -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"] diff --git a/app/radio/dj.py b/app/radio/dj.py new file mode 100644 index 0000000..73ac742 --- /dev/null +++ b/app/radio/dj.py @@ -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() diff --git a/app/radio/manager.py b/app/radio/manager.py new file mode 100644 index 0000000..8d466bc --- /dev/null +++ b/app/radio/manager.py @@ -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() diff --git a/app/radio/weather.py b/app/radio/weather.py new file mode 100644 index 0000000..9d8b5e0 --- /dev/null +++ b/app/radio/weather.py @@ -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 diff --git a/app/static/app.js b/app/static/app.js index 8cc16dc..3dafd8a 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -267,7 +267,7 @@ document.addEventListener("DOMContentLoaded", () => { function renderHistory(tracks) { if (!tracks || tracks.length === 0) { - historyBody.innerHTML = `Пока нет загрузок`; + historyBody.innerHTML = `Пока нет загрузок`; return; } @@ -278,6 +278,9 @@ document.addEventListener("DOMContentLoaded", () => { ${escapeHtml(t.title)} ${escapeHtml(t.album || 'Singles')} ${formatDate(t.downloaded_at)} + + + `).join(""); } @@ -413,14 +416,161 @@ document.addEventListener("DOMContentLoaded", () => { ${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад) -
+
+ +
`).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 => ` +
+
+
«${escapeHtml(s.text)}»
+
🎵 Перед треком: ${escapeHtml(s.before_track)}
+
+
+ `).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); + } + } }); diff --git a/app/static/style.css b/app/static/style.css index 73a25db..0cbb7a5 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -811,3 +811,182 @@ input[type="text"]:focus { 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; +} + diff --git a/app/queue.py b/app/sync_queue.py similarity index 100% rename from app/queue.py rename to app/sync_queue.py diff --git a/app/templates/index.html b/app/templates/index.html index e724b92..c80fc6f 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -143,6 +143,55 @@ + +
+
+
+

🎙️ AI Радио AI DJ Эфир 🤖 Qwen 2.5 + Edge-TTS

+

+ Персональный радиоэфир: AI-ведущий объявляет погоду, время суток и делает динамичные подводки перед песнями в отдельном плейлисте «AI Радио». +

+
+
+ +
+
+ + +
+
+ + +
+
+ Эфир готов к запуску +
+
+ +
+
+ Реплик ведущего: + 0 записей +
+
+ Эфирный плейлист: + AI Радио.m3u8 ↗ +
+
+ Чистая музыка (без ведущего): + Моя Волна.nsp ↗ +
+
+ + +
+

⚡ Очередь задач

@@ -163,11 +212,12 @@ Трек Альбом Дата + Действия - Пока нет загрузок + Пока нет загрузок diff --git a/app/wave/collector.py b/app/wave/collector.py index 31d9953..2257ef4 100644 --- a/app/wave/collector.py +++ b/app/wave/collector.py @@ -124,8 +124,16 @@ class WaveCollector: return profile 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() + + # 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() if not conn: return keys diff --git a/app/wave/rotator.py b/app/wave/rotator.py index 7277910..aa6f7e3 100644 --- a/app/wave/rotator.py +++ b/app/wave/rotator.py @@ -227,6 +227,100 @@ class WaveRotator: "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]: """Get summary status of Smart Wave.""" buffer_tracks = self.get_current_buffer() diff --git a/requirements.txt b/requirements.txt index 6d3ce00..bf2de7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,3 +11,5 @@ httpx>=0.27.0 jinja2>=3.1.0 python-multipart>=0.0.12 spotifyscraper>=3.9.0 +edge-tts>=6.1.12 +gtts>=2.5.0