aboutsummaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
authorStefanVukovic99 <stefanvukovic44@gmail.com>2024-06-27 18:08:42 +0200
committerGitHub <noreply@github.com>2024-06-27 16:08:42 +0000
commit603c2c7e1b50d8b06c06848c3e83d241da9437e6 (patch)
tree2e04bdb57475c630170315678d789751bfeb6444 /ext
parent4e3f23e942252dacb31780de30f0233eccf1d9f8 (diff)
add lingua libre audio source (#1129)
* add lingua libre audio source * mvp * run file requests in parallel * remove redundant language var * redundant api function --------- Co-authored-by: Cashew <52880648+cashewnuttynuts@users.noreply.github.com>
Diffstat (limited to 'ext')
-rw-r--r--ext/data/schemas/options-schema.json1
-rw-r--r--ext/js/background/backend.js7
-rw-r--r--ext/js/comm/api.js5
-rw-r--r--ext/js/data/anki-note-builder.js4
-rw-r--r--ext/js/display/display-anki.js14
-rw-r--r--ext/js/display/display-audio.js4
-rw-r--r--ext/js/display/display.js14
-rw-r--r--ext/js/language/language-descriptors.js31
-rwxr-xr-xext/js/language/languages.js4
-rw-r--r--ext/js/media/audio-downloader.js96
-rw-r--r--ext/js/pages/settings/audio-controller.js2
-rw-r--r--ext/templates-settings.html2
12 files changed, 136 insertions, 48 deletions
diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json
index dc230620..84619ab4 100644
--- a/ext/data/schemas/options-schema.json
+++ b/ext/data/schemas/options-schema.json
@@ -400,6 +400,7 @@
"jpod101",
"jpod101-alternate",
"jisho",
+ "lingua-libre",
"text-to-speech",
"text-to-speech-reading",
"custom",
diff --git a/ext/js/background/backend.js b/ext/js/background/backend.js
index a468a3f6..96e8206b 100644
--- a/ext/js/background/backend.js
+++ b/ext/js/background/backend.js
@@ -676,8 +676,8 @@ export class Backend {
}
/** @type {import('api').ApiHandler<'getTermAudioInfoList'>} */
- async _onApiGetTermAudioInfoList({source, term, reading}) {
- return await this._audioDownloader.getTermAudioInfoList(source, term, reading);
+ async _onApiGetTermAudioInfoList({source, term, reading, languageSummary}) {
+ return await this._audioDownloader.getTermAudioInfoList(source, term, reading, languageSummary);
}
/** @type {import('api').ApiHandler<'sendMessageToFrame'>} */
@@ -2171,7 +2171,7 @@ export class Backend {
const {term, reading} = definitionDetails;
if (term.length === 0 && reading.length === 0) { return null; }
- const {sources, preferredAudioIndex, idleTimeout} = details;
+ const {sources, preferredAudioIndex, idleTimeout, languageSummary} = details;
let data;
let contentType;
try {
@@ -2181,6 +2181,7 @@ export class Backend {
term,
reading,
idleTimeout,
+ languageSummary,
));
} catch (e) {
const error = this._getAudioDownloadError(e);
diff --git a/ext/js/comm/api.js b/ext/js/comm/api.js
index 7a98feb1..952b5bfa 100644
--- a/ext/js/comm/api.js
+++ b/ext/js/comm/api.js
@@ -147,10 +147,11 @@ export class API {
* @param {import('api').ApiParam<'getTermAudioInfoList', 'source'>} source
* @param {import('api').ApiParam<'getTermAudioInfoList', 'term'>} term
* @param {import('api').ApiParam<'getTermAudioInfoList', 'reading'>} reading
+ * @param {import('api').ApiParam<'getTermAudioInfoList', 'languageSummary'>} languageSummary
* @returns {Promise<import('api').ApiReturn<'getTermAudioInfoList'>>}
*/
- getTermAudioInfoList(source, term, reading) {
- return this._invoke('getTermAudioInfoList', {source, term, reading});
+ getTermAudioInfoList(source, term, reading, languageSummary) {
+ return this._invoke('getTermAudioInfoList', {source, term, reading, languageSummary});
}
/**
diff --git a/ext/js/data/anki-note-builder.js b/ext/js/data/anki-note-builder.js
index 38588439..7f7fe3a7 100644
--- a/ext/js/data/anki-note-builder.js
+++ b/ext/js/data/anki-note-builder.js
@@ -428,8 +428,8 @@ export class AnkiNoteBuilder {
if (injectAudio && dictionaryEntryDetails.type !== 'kanji') {
const audioOptions = mediaOptions.audio;
if (typeof audioOptions === 'object' && audioOptions !== null) {
- const {sources, preferredAudioIndex, idleTimeout} = audioOptions;
- audioDetails = {sources, preferredAudioIndex, idleTimeout};
+ const {sources, preferredAudioIndex, idleTimeout, languageSummary} = audioOptions;
+ audioDetails = {sources, preferredAudioIndex, idleTimeout, languageSummary};
}
}
if (injectScreenshot) {
diff --git a/ext/js/display/display-anki.js b/ext/js/display/display-anki.js
index fc242549..b3b05408 100644
--- a/ext/js/display/display-anki.js
+++ b/ext/js/display/display-anki.js
@@ -193,7 +193,11 @@ export class DisplayAnki {
*/
_onOptionsUpdated({options}) {
const {
- general: {resultOutputMode, glossaryLayoutMode, compactTags},
+ general: {
+ resultOutputMode,
+ glossaryLayoutMode,
+ compactTags,
+ },
dictionaries,
anki: {
tags,
@@ -883,7 +887,13 @@ export class DisplayAnki {
_getAnkiNoteMediaAudioDetails(details) {
if (details.type !== 'term') { return null; }
const {sources, preferredAudioIndex} = this._displayAudio.getAnkiNoteMediaAudioDetails(details.term, details.reading);
- return {sources, preferredAudioIndex, idleTimeout: this._audioDownloadIdleTimeout};
+ const languageSummary = this._display.getLanguageSummary();
+ return {
+ sources,
+ preferredAudioIndex,
+ idleTimeout: this._audioDownloadIdleTimeout,
+ languageSummary,
+ };
}
// View note functions
diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js
index c7e08ffe..0d1ca029 100644
--- a/ext/js/display/display-audio.js
+++ b/ext/js/display/display-audio.js
@@ -57,6 +57,7 @@ export class DisplayAudio {
['jpod101', 'JapanesePod101'],
['jpod101-alternate', 'JapanesePod101 (Alternate)'],
['jisho', 'Jisho.org'],
+ ['lingua-libre', 'Lingua Libre'],
['text-to-speech', 'Text-to-speech'],
['text-to-speech-reading', 'Text-to-speech (Kana reading)'],
['custom', 'Custom URL'],
@@ -677,7 +678,8 @@ export class DisplayAudio {
*/
async _getTermAudioInfoList(source, term, reading) {
const sourceData = this._getSourceData(source);
- const infoList = await this._display.application.api.getTermAudioInfoList(sourceData, term, reading);
+ const languageSummary = this._display.getLanguageSummary();
+ const infoList = await this._display.application.api.getTermAudioInfoList(sourceData, term, reading, languageSummary);
return infoList.map((info) => ({info, audioPromise: null, audioResolved: false, audio: null}));
}
diff --git a/ext/js/display/display.js b/ext/js/display/display.js
index f86d7b8c..3d18e416 100644
--- a/ext/js/display/display.js
+++ b/ext/js/display/display.js
@@ -192,6 +192,8 @@ export class Display extends EventDispatcher {
this._onMenuButtonMenuCloseBind = this._onMenuButtonMenuClose.bind(this);
/** @type {ThemeController} */
this._themeController = new ThemeController(document.documentElement);
+ /** @type {import('language').LanguageSummary[]} */
+ this._languageSummaries = [];
/* eslint-disable @stylistic/no-multi-spaces */
this._hotkeyHandler.registerActions([
@@ -316,6 +318,8 @@ export class Display extends EventDispatcher {
documentElement.dataset.browser = browser;
}
+ this._languageSummaries = await this._application.api.getLanguageSummaries();
+
// Prepare
await this._hotkeyHelpController.prepare(this._application.api);
await this._displayGenerator.prepare();
@@ -398,6 +402,16 @@ export class Display extends EventDispatcher {
}
/**
+ * @returns {import('language').LanguageSummary}
+ * @throws {Error}
+ */
+ getLanguageSummary() {
+ if (this._options === null) { throw new Error('Options is null'); }
+ const language = this._options.general.language;
+ return /** @type {import('language').LanguageSummary} */ (this._languageSummaries.find(({iso}) => iso === language));
+ }
+
+ /**
* @returns {import('settings').OptionsContext}
*/
getOptionsContext() {
diff --git a/ext/js/language/language-descriptors.js b/ext/js/language/language-descriptors.js
index f9fb4f09..2e8ece55 100644
--- a/ext/js/language/language-descriptors.js
+++ b/ext/js/language/language-descriptors.js
@@ -50,6 +50,7 @@ const capitalizationPreprocessors = {
const languageDescriptors = [
{
iso: 'ar',
+ iso639_3: 'ara',
name: 'Arabic',
exampleText: 'قَرَأَ',
textPreprocessors: {
@@ -58,6 +59,7 @@ const languageDescriptors = [
},
{
iso: 'de',
+ iso639_3: 'deu',
name: 'German',
exampleText: 'gelesen',
textPreprocessors: {
@@ -68,12 +70,14 @@ const languageDescriptors = [
},
{
iso: 'el',
+ iso639_3: 'ell',
name: 'Greek',
exampleText: 'διαβάζω',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'en',
+ iso639_3: 'eng',
name: 'English',
exampleText: 'read',
textPreprocessors: capitalizationPreprocessors,
@@ -81,6 +85,7 @@ const languageDescriptors = [
},
{
iso: 'es',
+ iso639_3: 'spa',
name: 'Spanish',
exampleText: 'leer',
textPreprocessors: capitalizationPreprocessors,
@@ -88,6 +93,7 @@ const languageDescriptors = [
},
{
iso: 'fa',
+ iso639_3: 'fas',
name: 'Persian',
exampleText: 'خواندن',
textPreprocessors: {
@@ -96,18 +102,21 @@ const languageDescriptors = [
},
{
iso: 'fi',
+ iso639_3: 'fin',
name: 'Finnish',
exampleText: 'lukea',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'fr',
+ iso639_3: 'fra',
name: 'French',
exampleText: 'lire',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'grc',
+ iso639_3: 'grc',
name: 'Ancient Greek',
exampleText: 'γράφω',
textPreprocessors: {
@@ -117,24 +126,28 @@ const languageDescriptors = [
},
{
iso: 'hu',
+ iso639_3: 'hun',
name: 'Hungarian',
exampleText: 'olvasni',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'id',
+ iso639_3: 'ind',
name: 'Indonesian',
exampleText: 'membaca',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'it',
+ iso639_3: 'ita',
name: 'Italian',
exampleText: 'leggere',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'la',
+ iso639_3: 'lat',
name: 'Latin',
exampleText: 'legere',
textPreprocessors: {
@@ -145,11 +158,13 @@ const languageDescriptors = [
},
{
iso: 'lo',
+ iso639_3: 'lao',
name: 'Lao',
exampleText: 'ອ່ານ',
},
{
iso: 'ja',
+ iso639_3: 'jpn',
name: 'Japanese',
exampleText: '読め',
isTextLookupWorthy: isStringPartiallyJapanese,
@@ -165,11 +180,13 @@ const languageDescriptors = [
},
{
iso: 'km',
+ iso639_3: 'khm',
name: 'Khmer',
exampleText: 'អាន',
},
{
iso: 'ko',
+ iso639_3: 'kor',
name: 'Korean',
exampleText: '읽어',
textPreprocessors: {
@@ -182,24 +199,28 @@ const languageDescriptors = [
},
{
iso: 'nl',
+ iso639_3: 'nld',
name: 'Dutch',
exampleText: 'lezen',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'pl',
+ iso639_3: 'pol',
name: 'Polish',
exampleText: 'czytacie',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'pt',
+ iso639_3: 'por',
name: 'Portuguese',
exampleText: 'ler',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'ro',
+ iso639_3: 'ron',
name: 'Romanian',
exampleText: 'citit',
textPreprocessors: {
@@ -209,6 +230,7 @@ const languageDescriptors = [
},
{
iso: 'ru',
+ iso639_3: 'rus',
name: 'Russian',
exampleText: 'читать',
textPreprocessors: {
@@ -219,6 +241,7 @@ const languageDescriptors = [
},
{
iso: 'sga',
+ iso639_3: 'sga',
name: 'Old Irish',
exampleText: 'légaid',
textPreprocessors: {
@@ -229,6 +252,7 @@ const languageDescriptors = [
},
{
iso: 'sh',
+ iso639_3: 'hbs',
name: 'Serbo-Croatian',
exampleText: 'čitaše',
textPreprocessors: {
@@ -238,6 +262,7 @@ const languageDescriptors = [
},
{
iso: 'sq',
+ iso639_3: 'sqi',
name: 'Albanian',
exampleText: 'ndihmojme',
textPreprocessors: capitalizationPreprocessors,
@@ -245,23 +270,27 @@ const languageDescriptors = [
},
{
iso: 'sv',
+ iso639_3: 'swe',
name: 'Swedish',
exampleText: 'läsa',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'th',
+ iso639_3: 'tha',
name: 'Thai',
exampleText: 'อ่าน',
},
{
iso: 'tr',
+ iso639_3: 'tur',
name: 'Turkish',
exampleText: 'okuyor',
textPreprocessors: capitalizationPreprocessors,
},
{
iso: 'vi',
+ iso639_3: 'vie',
name: 'Vietnamese',
exampleText: 'đọc',
textPreprocessors: {
@@ -271,11 +300,13 @@ const languageDescriptors = [
},
{
iso: 'yue',
+ iso639_3: 'yue',
name: 'Cantonese',
exampleText: '讀',
},
{
iso: 'zh',
+ iso639_3: 'zho',
name: 'Chinese',
exampleText: '读',
isTextLookupWorthy: isStringPartiallyChinese,
diff --git a/ext/js/language/languages.js b/ext/js/language/languages.js
index 7759fda5..96f7a080 100755
--- a/ext/js/language/languages.js
+++ b/ext/js/language/languages.js
@@ -22,8 +22,8 @@ import {languageDescriptorMap} from './language-descriptors.js';
*/
export function getLanguageSummaries() {
const results = [];
- for (const {name, iso, exampleText} of languageDescriptorMap.values()) {
- results.push({name, iso, exampleText});
+ for (const {name, iso, iso639_3, exampleText} of languageDescriptorMap.values()) {
+ results.push({name, iso, iso639_3, exampleText});
}
return results;
}
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js
index 2d1bc4ec..d378d043 100644
--- a/ext/js/media/audio-downloader.js
+++ b/ext/js/media/audio-downloader.js
@@ -25,6 +25,16 @@ import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
import {SimpleDOMParser} from '../dom/simple-dom-parser.js';
import {isStringEntirelyKana} from '../language/ja/japanese.js';
+/** @type {RequestInit} */
+const DEFAULT_REQUEST_INIT_PARAMS = {
+ method: 'GET',
+ mode: 'cors',
+ cache: 'default',
+ credentials: 'omit',
+ redirect: 'follow',
+ referrerPolicy: 'no-referrer',
+};
+
export class AudioDownloader {
/**
* @param {RequestBuilder} requestBuilder
@@ -39,6 +49,7 @@ export class AudioDownloader {
['jpod101', this._getInfoJpod101.bind(this)],
['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)],
['jisho', this._getInfoJisho.bind(this)],
+ ['lingua-libre', this._getInfoLinguaLibre.bind(this)],
['text-to-speech', this._getInfoTextToSpeech.bind(this)],
['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)],
['custom', this._getInfoCustom.bind(this)],
@@ -50,13 +61,14 @@ export class AudioDownloader {
* @param {import('audio').AudioSourceInfo} source
* @param {string} term
* @param {string} reading
+ * @param {import('language').LanguageSummary} languageSummary
* @returns {Promise<import('audio-downloader').Info[]>}
*/
- async getTermAudioInfoList(source, term, reading) {
+ async getTermAudioInfoList(source, term, reading, languageSummary) {
const handler = this._getInfoHandlers.get(source.type);
if (typeof handler === 'function') {
try {
- return await handler(term, reading, source);
+ return await handler(term, reading, source, languageSummary);
} catch (e) {
// NOP
}
@@ -70,12 +82,13 @@ export class AudioDownloader {
* @param {string} term
* @param {string} reading
* @param {?number} idleTimeout
+ * @param {import('language').LanguageSummary} languageSummary
* @returns {Promise<import('audio-downloader').AudioBinaryBase64>}
*/
- async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout) {
+ async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout, languageSummary) {
const errors = [];
for (const source of sources) {
- let infoList = await this.getTermAudioInfoList(source, term, reading);
+ let infoList = await this.getTermAudioInfoList(source, term, reading, languageSummary);
if (typeof preferredAudioIndex === 'number') {
infoList = (preferredAudioIndex >= 0 && preferredAudioIndex < infoList.length ? [infoList[preferredAudioIndex]] : []);
}
@@ -137,12 +150,8 @@ export class AudioDownloader {
vulgar: 'true',
});
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
+ ...DEFAULT_REQUEST_INIT_PARAMS,
method: 'POST',
- mode: 'cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
@@ -181,14 +190,7 @@ export class AudioDownloader {
/** @type {import('audio-downloader').GetInfoHandler} */
async _getInfoJisho(term, reading) {
const fetchUrl = `https://jisho.org/search/${term}`;
- const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
- method: 'GET',
- mode: 'cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer',
- });
+ const response = await this._requestBuilder.fetchAnonymous(fetchUrl, DEFAULT_REQUEST_INIT_PARAMS);
const responseText = await response.text();
const dom = this._createSimpleDOMParser(responseText);
@@ -212,6 +214,44 @@ export class AudioDownloader {
}
/** @type {import('audio-downloader').GetInfoHandler} */
+ async _getInfoLinguaLibre(term, _reading, _details, languageSummary) {
+ if (typeof languageSummary !== 'object' || languageSummary === null) {
+ throw new Error('Invalid arguments');
+ }
+ const {iso639_3} = languageSummary;
+ const searchCategory = `incategory:"Lingua_Libre_pronunciation-${iso639_3}"`;
+ const searchString = `-${term}.wav`;
+ const fetchUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&list=search&srsearch=intitle:/${searchString}/i+${searchCategory}&srnamespace=6&origin=*`;
+
+ const response = await this._requestBuilder.fetchAnonymous(fetchUrl, DEFAULT_REQUEST_INIT_PARAMS);
+
+ /** @type {import('audio-downloader').LinguaLibreLookupResponse} */
+ const lookupResponse = await readResponseJson(response);
+
+ const lookupResults = lookupResponse.query.search;
+
+ const fetchFileInfos = lookupResults.map(async ({title}) => {
+ const fileInfoURL = `https://commons.wikimedia.org/w/api.php?action=query&format=json&titles=${title}&prop=imageinfo&iiprop=user|url&origin=*`;
+ const response2 = await this._requestBuilder.fetchAnonymous(fileInfoURL, DEFAULT_REQUEST_INIT_PARAMS);
+ /** @type {import('audio-downloader').LinguaLibreFileResponse} */
+ const fileResponse = await readResponseJson(response2);
+ const fileResults = fileResponse.query.pages;
+ const results = [];
+ for (const page of Object.values(fileResults)) {
+ const fileUrl = page.imageinfo[0].url;
+ const fileUser = page.imageinfo[0].user;
+ const validFilenameTest = new RegExp(`^File:LL-Q\\d+\\s+\\(${iso639_3}\\)-(\\w+ \\()?${fileUser}\\)?-${term}\\.wav$`, 'i');
+ if (validFilenameTest.test(title)) {
+ results.push({type: 'url', url: fileUrl, name: fileUser});
+ }
+ }
+ return /** @type {import('audio-downloader').Info1[]} */ (results);
+ });
+
+ return (await Promise.all(fetchFileInfos)).flat();
+ }
+
+ /** @type {import('audio-downloader').GetInfoHandler} */
async _getInfoTextToSpeech(term, reading, details) {
if (typeof details !== 'object' || details === null) {
throw new Error('Invalid arguments');
@@ -259,14 +299,7 @@ export class AudioDownloader {
}
url = this._getCustomUrl(term, reading, url);
- const response = await this._requestBuilder.fetchAnonymous(url, {
- method: 'GET',
- mode: 'cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer',
- });
+ const response = await this._requestBuilder.fetchAnonymous(url, DEFAULT_REQUEST_INIT_PARAMS);
if (!response.ok) {
throw new Error(`Invalid response: ${response.status}`);
@@ -345,12 +378,7 @@ export class AudioDownloader {
}
const response = await this._requestBuilder.fetchAnonymous(url, {
- method: 'GET',
- mode: 'cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer',
+ ...DEFAULT_REQUEST_INIT_PARAMS,
signal,
});
@@ -429,12 +457,8 @@ export class AudioDownloader {
async _getCustomAudioListSchema() {
const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json');
const response = await fetch(url, {
- method: 'GET',
+ ...DEFAULT_REQUEST_INIT_PARAMS,
mode: 'no-cors',
- cache: 'default',
- credentials: 'omit',
- redirect: 'follow',
- referrerPolicy: 'no-referrer',
});
return await readResponseJson(response);
}
diff --git a/ext/js/pages/settings/audio-controller.js b/ext/js/pages/settings/audio-controller.js
index 34d7adaa..ac79b51a 100644
--- a/ext/js/pages/settings/audio-controller.js
+++ b/ext/js/pages/settings/audio-controller.js
@@ -221,6 +221,7 @@ export class AudioController extends EventDispatcher {
'jpod101',
'jpod101-alternate',
'jisho',
+ 'lingua-libre',
'custom',
];
for (const type of typesAvailable) {
@@ -488,6 +489,7 @@ class AudioSourceEntry {
case 'jpod101':
case 'jpod101-alternate':
case 'jisho':
+ case 'lingua-libre':
case 'text-to-speech':
case 'text-to-speech-reading':
case 'custom':
diff --git a/ext/templates-settings.html b/ext/templates-settings.html
index 3112d30f..2443843b 100644
--- a/ext/templates-settings.html
+++ b/ext/templates-settings.html
@@ -116,6 +116,7 @@
<option value="jpod101">JapanesePod101</option>
<option value="jpod101-alternate">JapanesePod101 (Alternate)</option>
<option value="jisho">Jisho.org</option>
+ <option value="lingua-libre">Lingua Libre</option>
<option value="text-to-speech">Text-to-speech</option>
<option value="text-to-speech-reading">Text-to-speech (Kana reading)</option>
<option value="custom">Custom URL</option>
@@ -429,6 +430,7 @@
<option value="jpod101">JapanesePod101</option>
<option value="jpod101-alternate">JapanesePod101 (Alternate)</option>
<option value="jisho">Jisho.org</option>
+ <option value="lingua-libre">Lingua Libre</option>
<option value="text-to-speech">Text-to-speech</option>
<option value="text-to-speech-reading">Text-to-speech (Kana reading)</option>
<option value="custom">Custom</option>