Files
Antigravity 4ed8e0c71a feat(radio): replace robot TTS with Piper Neural voices & add instant voice preview
- Integrate local Piper ONNX neural TTS engine (Dmitri, Irina, Denis, Ruslan)
- Add GET /api/radio/preview-voice endpoint with cached pre-rendered samples
- Add '▶ Прослушать голос' button in Web UI for 1-click preview before generating
- Add automated filter to strip LLM refusals and fall back to clean radio phrasing
2026-10-12 16:58:59 +03:00

608 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
document.addEventListener("DOMContentLoaded", () => {
const downloadForm = document.getElementById("downloadForm");
const urlInput = document.getElementById("urlInput");
const syncLikesBtn = document.getElementById("syncLikesBtn");
const tasksList = document.getElementById("tasksList");
const historyBody = document.getElementById("historyBody");
const tracksCount = document.getElementById("tracksCount");
// Search elements
const searchCard = document.getElementById("searchCard");
const searchQueryLabel = document.getElementById("searchQueryLabel");
const searchLoading = document.getElementById("searchLoading");
const searchTracksList = document.getElementById("searchTracksList");
const searchAlbumsList = document.getElementById("searchAlbumsList");
const closeSearchBtn = document.getElementById("closeSearchBtn");
// Load history and active tasks
fetchHistory();
fetchTasks();
setInterval(fetchTasks, 2000);
setInterval(fetchHistory, 10000);
// Form submit
downloadForm.addEventListener("submit", async (e) => {
e.preventDefault();
const val = urlInput.value.trim();
if (!val) return;
// Check if it's a direct URL
if (val.startsWith("http://") || val.startsWith("https://")) {
await startDownload(val);
urlInput.value = "";
} else {
// It's a search query!
await performSearch(val);
}
});
// Close search
closeSearchBtn.addEventListener("click", () => {
searchCard.style.display = "none";
});
// Perform search
async function performSearch(query) {
searchCard.style.display = "block";
searchQueryLabel.textContent = `«${query}»`;
searchLoading.style.display = "block";
searchTracksList.innerHTML = "";
searchAlbumsList.innerHTML = "";
try {
const res = await fetch(`api/search?q=${encodeURIComponent(query)}`);
const data = await res.json();
searchLoading.style.display = "none";
renderSearchResults(data);
} catch (err) {
searchLoading.style.display = "none";
searchTracksList.innerHTML = `<p class="empty-state">Ошибка поиска: ${err}</p>`;
}
}
function renderSearchResults(data) {
const tracks = data.tracks || [];
const albums = data.albums || [];
// Tracks
if (tracks.length === 0) {
searchTracksList.innerHTML = `<p class="empty-state">Треков не найдено</p>`;
} else {
searchTracksList.innerHTML = tracks.map(t => {
const explicitBadge = t.explicit_status === 'explicit'
? `<span class="badge badge-explicit" title="Оригинал: текст без цензуры (18+)">🔞 18+ Explicit</span>`
: (t.explicit_status === 'clean'
? `<span class="badge badge-clean" title="Внимание: трек зацензурен (слова запиканы / вырезаны)">⚠️ Clean (Цензура)</span>`
: '');
const versionBadge = t.version ? `<span class="badge badge-version">${escapeHtml(t.version)}</span>` : '';
return `
<div class="search-track-row" id="track-row-${escapeHtml(t.id)}">
<div class="track-left">
<img class="track-thumb" src="${t.cover || 'static/placeholder.png'}" onerror="this.src='data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'44\' height=\'44\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%2364748b\' stroke-width=\'2\'><rect x=\'3\' y=\'3\' width=\'18\' height=\'18\' rx=\'2\'/><circle cx=\'12\' cy=\'12\' r=\'4\'/></svg>'">
<div class="track-details">
<div style="display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap;">
<span class="track-title">${escapeHtml(t.title)}</span>
${explicitBadge}
${versionBadge}
</div>
<span class="track-meta">
<span class="tag-badge tag-${t.service}">${t.service_name}</span> • ${escapeHtml(t.artist)} • ${escapeHtml(t.album || 'Single')} (${t.duration})
</span>
</div>
</div>
<div class="track-actions">
<button class="btn-play-preview" onclick="playPreviewTrack('${escapeAttr(t.url)}', '${escapeAttr(t.title)}', '${escapeAttr(t.artist)}', '${escapeAttr(t.cover || '')}', '${escapeAttr(t.explicit_status || '')}', '${escapeAttr(t.preview_url || '')}')">
▶ Слушать
</button>
<button class="btn-sm btn-import" onclick="handleImport('${escapeAttr(t.url)}')">📥 В Navidrome</button>
<a class="btn-sm btn-direct-dl" href="api/direct-download?url=${encodeURIComponent(t.url)}" download>💾 Скачать</a>
</div>
</div>
`;
}).join("");
}
// Albums
if (albums.length === 0) {
document.getElementById("searchAlbumsSection").style.display = "none";
} else {
document.getElementById("searchAlbumsSection").style.display = "block";
searchAlbumsList.innerHTML = albums.map(a => `
<div class="album-card">
<img class="album-thumb" src="${a.cover || ''}" onerror="this.src='data:image/svg+xml;utf8,<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'120\' height=\'120\' viewBox=\'0 0 24 24\' fill=\'none\' stroke=\'%2364748b\' stroke-width=\'2\'><circle cx=\'12\' cy=\'12\' r=\'10\'/><circle cx=\'12\' cy=\'12\' r=\'3\'/></svg>'">
<span class="album-title">${escapeHtml(a.title)}</span>
<span class="album-meta">
<span class="tag-badge tag-${a.service}">${a.service_name}</span> • ${a.year || ''} • ${a.track_count ? a.track_count + ' треков' : ''}
</span>
<button class="btn-sm btn-import" style="margin-top: auto;" onclick="handleImport('${escapeAttr(a.url)}')">📥 Импортировать альбом</button>
</div>
`).join("");
}
}
// Audio Preview Player
let currentPlayingUrl = null;
window.playPreviewTrack = function(url, title, artist, cover, explicitStatus, previewUrl) {
const bar = document.getElementById("audioPlayerBar");
const audio = document.getElementById("globalAudioPlayer");
const playerCover = document.getElementById("playerCover");
const playerTitle = document.getElementById("playerTitle");
const playerArtist = document.getElementById("playerArtist");
const playerBadge = document.getElementById("playerExplicitBadge");
const playerImportBtn = document.getElementById("playerImportBtn");
const playerDownloadBtn = document.getElementById("playerDownloadBtn");
if (currentPlayingUrl === url && !audio.paused) {
audio.pause();
return;
}
currentPlayingUrl = url;
playerCover.src = cover || "static/placeholder.png";
playerTitle.textContent = title;
playerArtist.textContent = artist;
if (explicitStatus === "explicit") {
playerBadge.className = "badge badge-explicit";
playerBadge.textContent = "🔞 18+ Explicit (Без цензуры)";
playerBadge.style.display = "inline-flex";
} else if (explicitStatus === "clean") {
playerBadge.className = "badge badge-clean";
playerBadge.textContent = "⚠️ Clean (С цензурой)";
playerBadge.style.display = "inline-flex";
} else {
playerBadge.style.display = "none";
}
playerImportBtn.onclick = () => handleImport(url);
playerDownloadBtn.onclick = () => {
window.location.href = "api/direct-download?url=" + encodeURIComponent(url);
};
bar.style.display = "flex";
// Choose source: Apple 30s direct preview or server stream with HTTP Range
const streamSrc = previewUrl || ("api/stream?url=" + encodeURIComponent(url));
audio.src = streamSrc;
audio.play().catch(e => console.warn("Playback error:", e));
};
const playerCloseBtn = document.getElementById("playerCloseBtn");
if (playerCloseBtn) {
playerCloseBtn.addEventListener("click", () => {
const audio = document.getElementById("globalAudioPlayer");
audio.pause();
audio.src = "";
currentPlayingUrl = null;
document.getElementById("audioPlayerBar").style.display = "none";
});
}
// Global handler for import button
window.handleImport = async function(url) {
await startDownload(url);
};
async function startDownload(url) {
try {
const res = await fetch("api/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url })
});
const data = await res.json();
if (data.task_id) {
fetchTasks();
window.scrollTo({ top: tasksList.offsetTop - 100, behavior: "smooth" });
} else {
alert(data.error || "Ошибка запуска загрузки");
}
} catch (err) {
alert("Ошибка сети: " + err);
}
}
// Sync likes button
syncLikesBtn.addEventListener("click", async () => {
if (!confirm("Запустить синхронизацию плейлиста «Мне нравится» из Яндекс Музыки?")) return;
try {
const res = await fetch("api/sync/yandex-likes", { method: "POST" });
const data = await res.json();
if (data.task_id) {
fetchTasks();
}
} catch (err) {
alert("Ошибка запуска синхронизации: " + err);
}
});
async function fetchTasks() {
try {
const res = await fetch("api/tasks");
const tasks = await res.json();
renderTasks(tasks);
} catch (err) {
console.error(err);
}
}
function renderTasks(tasks) {
if (!tasks || tasks.length === 0) {
tasksList.innerHTML = `<p class="empty-state">Нет активных загрузок</p>`;
return;
}
tasksList.innerHTML = tasks.map(t => {
const percent = t.total > 0 ? Math.round((t.progress / t.total) * 100) : 0;
return `
<div class="task-item">
<div class="task-header">
<strong>${escapeHtml(t.target)}</strong>
<span class="badge ${t.status === 'completed' ? 'badge-success' : 'badge-info'}">${t.status}</span>
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${percent}%;"></div>
</div>
<div class="task-current">
${t.progress}/${t.total} (${percent}%) • ${escapeHtml(t.current_item || '')}
</div>
</div>
`;
}).join("");
}
async function fetchHistory() {
try {
const res = await fetch("api/history");
const data = await res.json();
if (tracksCount) tracksCount.textContent = `Загружено: ${data.total || 0}`;
renderHistory(data.tracks || []);
} catch (err) {
console.error(err);
}
}
function renderHistory(tracks) {
if (!tracks || tracks.length === 0) {
historyBody.innerHTML = `<tr><td colspan="6" class="empty-state">Пока нет загрузок</td></tr>`;
return;
}
historyBody.innerHTML = tracks.map(t => `
<tr>
<td><span class="tag-badge tag-${t.service}">${t.service}</span></td>
<td><strong>${escapeHtml(t.artist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(t.album || 'Singles')}</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>
`).join("");
}
function escapeHtml(str) {
if (!str) return "";
return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function escapeAttr(str) {
if (!str) return "";
return str.replace(/'/g, "\\'").replace(/"/g, "&quot;");
}
function formatDate(isoStr) {
if (!isoStr) return "";
const d = new Date(isoStr);
return d.toLocaleDateString() + " " + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
}
// --- Smart Wave Logic ---
const waveCountLabel = document.getElementById("waveCountLabel");
const waveSizeLabel = document.getElementById("waveSizeLabel");
const waveAnchorsContainer = document.getElementById("waveAnchorsContainer");
const waveAnchorsList = document.getElementById("waveAnchorsList");
const waveTracksList = document.getElementById("waveTracksList");
const waveLoadingText = document.getElementById("waveLoadingText");
const waveGenerateBtn = document.getElementById("waveGenerateBtn");
const waveRotateBtn = document.getElementById("waveRotateBtn");
const waveMoodSelect = document.getElementById("waveMoodSelect");
const waveContextLabel = document.getElementById("waveContextLabel");
fetchWaveStatus();
if (waveGenerateBtn) {
waveGenerateBtn.addEventListener("click", async () => {
waveGenerateBtn.disabled = true;
const mood = waveMoodSelect ? waveMoodSelect.value : "auto";
if (waveLoadingText) {
waveLoadingText.innerHTML = `<span class="spinner-sm"></span> Qwen 2.5 отбирает треки под вайб...`;
waveLoadingText.style.display = "inline-block";
}
try {
const res = await fetch("api/wave/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mood: mood })
});
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
alert(`🌊 Волна обновлена под настроение [${data.mood_label || mood}]!\nДобавлено новых треков: ${data.added_count || 0}.`);
await fetchWaveStatus();
} catch (err) {
alert("Ошибка генерации волны: " + err);
} finally {
waveGenerateBtn.disabled = false;
if (waveLoadingText) waveLoadingText.style.display = "none";
}
});
}
if (waveRotateBtn) {
waveRotateBtn.addEventListener("click", async () => {
waveRotateBtn.disabled = true;
try {
const res = await fetch("api/wave/rotate", { method: "POST" });
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
const promMsg = (data.promoted_count > 0) ? `⭐ Перенесено в библиотеку: ${data.promoted_count}` : "Нет лайкнутых для переноса";
const purMsg = (data.purged_count > 0) ? `🗑️ Удалено устаревших: ${data.purged_count}` : "Нет устаревших";
alert(`Ротация завершена!\n${promMsg}\n${purMsg}`);
await fetchWaveStatus();
} catch (err) {
alert("Ошибка ротации: " + err);
} finally {
waveRotateBtn.disabled = false;
}
});
}
async function fetchWaveStatus() {
try {
const res = await fetch("api/wave/status");
if (res.status === 401) {
window.location.href = "login";
return;
}
const data = await res.json();
renderWaveStatus(data);
} catch (err) {
console.error("Wave status error:", err);
}
}
function renderWaveStatus(data) {
if (waveCountLabel) waveCountLabel.textContent = `${data.count || 0} / ${data.capacity || 25} треков`;
if (waveSizeLabel) waveSizeLabel.textContent = `${data.total_size_mb || 0} МБ`;
// Context label
if (waveContextLabel && data.current_context) {
waveContextLabel.textContent = data.current_context.label || "🕒 Авто";
waveContextLabel.title = data.current_context.desc || "";
}
// Anchors
if (waveAnchorsContainer && waveAnchorsList) {
const anchors = data.anchor_artists || [];
if (anchors.length > 0) {
waveAnchorsContainer.style.display = "block";
waveAnchorsList.innerHTML = anchors.map(a => `<span class="tag-anchor">${escapeHtml(a)}</span>`).join("");
} else {
waveAnchorsContainer.style.display = "none";
}
}
// Tracks list
if (waveTracksList) {
const tracks = data.tracks || [];
if (tracks.length === 0) {
waveTracksList.innerHTML = `<p class="empty-state">Волна пуста. Нажмите «Обновить Волну» для генерации первого потока.</p>`;
} else {
waveTracksList.innerHTML = tracks.map(t => `
<div class="wave-track-item">
<div style="min-width: 0; display: flex; flex-direction: column; gap: 0.15rem;">
<strong style="font-size: 0.88rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(t.title)}</strong>
<span style="font-size: 0.78rem; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${escapeHtml(t.artist)} • ${t.size_mb} МБ (${t.age_days} дн. назад)
</span>
</div>
<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>
<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>
`).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 radioVoicePreviewBtn = document.getElementById("radioVoicePreviewBtn");
const radioStatusLabel = document.getElementById("radioStatusLabel");
const radioClipsCountLabel = document.getElementById("radioClipsCountLabel");
const radioSegmentsContainer = document.getElementById("radioSegmentsContainer");
const radioSegmentsList = document.getElementById("radioSegmentsList");
fetchRadioStatus();
// Voice sample preview playback
if (radioVoicePreviewBtn) {
radioVoicePreviewBtn.addEventListener("click", () => {
const selectedVoice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
const audioUrl = `api/radio/preview-voice?voice=${encodeURIComponent(selectedVoice)}`;
const selectedOption = (radioVoiceSelect && radioVoiceSelect.options[radioVoiceSelect.selectedIndex])
? radioVoiceSelect.options[radioVoiceSelect.selectedIndex].text
: "Голос ведущего";
playPreviewTrack(
audioUrl,
`Сэмпл: ${selectedOption}`,
"AI Радио (Предпрослушивание)",
"",
"",
audioUrl
);
});
}
if (radioGenerateBtn) {
radioGenerateBtn.addEventListener("click", async () => {
radioGenerateBtn.disabled = true;
const voice = radioVoiceSelect ? radioVoiceSelect.value : "ru_RU-dmitri-medium";
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} записей`;
}
// Populate voice selector dynamically
if (radioVoiceSelect && data.available_voices && data.available_voices.length > 0) {
const currentVal = radioVoiceSelect.value || data.default_voice;
radioVoiceSelect.innerHTML = data.available_voices.map(v => `
<option value="${escapeHtml(v.id)}" ${v.id === currentVal ? "selected" : ""}>
🎙️ ${escapeHtml(v.name)}
</option>
`).join("");
}
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);
}
}
});