`).join('');
}
async function searchOpenSources(ev) {
ev?.preventDefault();
const st = $('os-status'), btn = $('os-search-btn');
const q = ($('os-q').value || '').trim();
if (!q) { st.textContent = 'Enter a search term.'; return; }
btn.disabled = true; st.textContent = 'Searching...';
try {
const p = new URLSearchParams({q, lang: $('os-lang').value});
const r = await fetch(`/api/voice-sources/${encodeURIComponent($('os-source').value)}?${p}`);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || r.statusText);
_osItems = d.items || [];
renderOpenSources();
st.textContent = `${_osItems.length} result${_osItems.length === 1 ? '' : 's'}.`;
} catch (e) {
_osItems = []; renderOpenSources(); st.textContent = 'Search failed: ' + e.message;
} finally { btn.disabled = false; }
}
// LibriVox list items carry only book_id; the first chapter's audio is fetched on demand.
async function resolveOpenSource(it) {
if (it.audio_url || !it.book_id) return;
const r = await fetch(`/api/voice-sources/librivox/book/${encodeURIComponent(it.book_id)}`);
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || r.statusText);
Object.assign(it, {audio_url: d.audio_url, reader: d.reader || it.reader, duration: d.duration || it.duration,
title: d.section ? `${it.title} - ${d.section}` : it.title});
}
async function importOpenSource(it) {
const st = $('os-status');
st.textContent = 'Looking up audio...';
try { await resolveOpenSource(it); } catch (e) { st.textContent = 'Lookup failed: ' + e.message; return; }
const lang = sourceLanguageCode(it.language || $('os-lang').value || 'English');
const body = {
audio_url: it.audio_url, voice_id: suggestedVoiceIdFromSourceName(it.title, lang, 'N'), transcript: '', lang,
source_kind: it.source, page_url: it.source_url, licence: it.licence, attribution: it.attribution || '',
name: it.title, group: it.source, origin: 'cloned', start: +$('os-start').value || 0, length: +$('os-len').value || 30,
note: `${it.reader || ''} ${it.licence}`.trim(),
};
st.textContent = 'Importing...';
try {
const r = await fetch('/api/quick-import-voice', {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
const d = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(d.detail || r.statusText);
st.textContent = `Saved as ${d.voice_id} (source and licence stored).`;
toast(`Saved as ${d.voice_id}`, 'success');
_libraryLoadPromise = null;
} catch (e) { st.textContent = 'Import failed: ' + e.message; toast('Import failed: ' + e.message, 'error'); }
}
$('os-form')?.addEventListener('submit', searchOpenSources);
$('os-list')?.addEventListener('click', async e => {
const l = e.target.closest('.os-load');
if (l && _osItems[+l.dataset.i]) {
l.disabled = true; $('os-status').textContent = 'Loading preview...';
try { await resolveOpenSource(_osItems[+l.dataset.i]); renderOpenSources(); $('os-status').textContent = 'Preview ready.'; }
catch (err) { l.disabled = false; $('os-status').textContent = 'Preview failed: ' + err.message; }
return;
}
const b = e.target.closest('.os-import');
if (b && _osItems[+b.dataset.i]) importOpenSource(_osItems[+b.dataset.i]);
});
const SOURCE_LANGUAGE_CODES = {
english:'EN', german:'DE', deutsch:'DE', french:'FR', spanish:'ES', japanese:'JA', korean:'KO',
italian:'IT', portuguese:'PT', russian:'RU', arabic:'AR', polish:'PL', dutch:'NL', swedish:'SV',
turkish:'TR', hindi:'HI', chinese:'ZH'
};
function sourceLanguageCode(language) {
const raw = String(language || '').trim();
// ElevenLabs' API returns language as a lowercase ISO code ("fr", "de"),
// not the uppercase form this used to require — the case-sensitive regex
// rejected it, and the name-lookup table below only has full language
// NAMES as keys ("french"), not ISO codes, so it silently fell through to
// the 'EN' default for every non-English ElevenLabs import (confirmed
// live: a voice tagged "FR" throughout the browse UI still imported as
// EN_...). Match the 2-letter code regardless of case.
if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase();
return SOURCE_LANGUAGE_CODES[raw.toLowerCase()] || 'EN';
}
function sourceGenderCode(gender, name = '') {
const text = `${gender || ''} ${name || ''}`.toLowerCase();
if (/female|woman|girl|\bf\b/.test(text)) return 'F';
if (/male|man|boy|\bm\b/.test(text)) return 'M';
return 'N';
}
function suggestedVoiceIdFromSourceName(name, language = 'EN', gender = 'N') {
const base = String(name || 'SourceVoice')
.normalize('NFKD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^A-Za-z0-9]+/g, '_')
.replace(/^_+|_+$/g, '')
.slice(0, 48) || 'SourceVoice';
return `${language || 'EN'}_${gender || 'N'}_${base}`;
}
function setLibAddSourcePreview(meta = {}) {
const sourceBox = document.querySelector('.lib-add-source-box');
if (!sourceBox) return;
let preview = $('lib-add-source-preview');
if (!preview) {
preview = document.createElement('div');
preview.id = 'lib-add-source-preview';
preview.className = 'lib-add-source-preview';
sourceBox.appendChild(preview);
}
if (!meta.name && !meta.imageUrl) {
preview.classList.remove('open');
preview.innerHTML = '';
return;
}
const image = meta.imageUrl ? `` : '';
preview.innerHTML = `${image}
${escHtml(meta.name || 'Source voice')}${escHtml([meta.language, meta.gender, meta.kind].filter(Boolean).join(' · ') || 'Source metadata will be saved with the voice')}