diff options
Diffstat (limited to 'ext/js/media/audio-downloader.js')
-rw-r--r-- | ext/js/media/audio-downloader.js | 126 |
1 files changed, 109 insertions, 17 deletions
diff --git a/ext/js/media/audio-downloader.js b/ext/js/media/audio-downloader.js index 1720a5d9..e041cc67 100644 --- a/ext/js/media/audio-downloader.js +++ b/ext/js/media/audio-downloader.js @@ -17,17 +17,25 @@ */ import {RequestBuilder} from '../background/request-builder.js'; +import {ExtensionError} from '../core/extension-error.js'; import {JsonSchema} from '../data/json-schema.js'; import {ArrayBufferUtil} from '../data/sandbox/array-buffer-util.js'; import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js'; import {SimpleDOMParser} from '../dom/simple-dom-parser.js'; export class AudioDownloader { + /** + * @param {{japaneseUtil: import('../language/sandbox/japanese-util.js').JapaneseUtil, requestBuilder: RequestBuilder}} details + */ constructor({japaneseUtil, requestBuilder}) { + /** @type {import('../language/sandbox/japanese-util.js').JapaneseUtil} */ this._japaneseUtil = japaneseUtil; + /** @type {RequestBuilder} */ this._requestBuilder = requestBuilder; + /** @type {?JsonSchema} */ this._customAudioListSchema = null; - this._getInfoHandlers = new Map([ + /** @type {Map<import('settings').AudioSourceType, import('audio-downloader').GetInfoHandler>} */ + this._getInfoHandlers = new Map(/** @type {[name: import('settings').AudioSourceType, handler: import('audio-downloader').GetInfoHandler][]} */ ([ ['jpod101', this._getInfoJpod101.bind(this)], ['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)], ['jisho', this._getInfoJisho.bind(this)], @@ -35,9 +43,15 @@ export class AudioDownloader { ['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)], ['custom', this._getInfoCustom.bind(this)], ['custom-json', this._getInfoCustomJson.bind(this)] - ]); + ])); } + /** + * @param {import('audio').AudioSourceInfo} source + * @param {string} term + * @param {string} reading + * @returns {Promise<import('audio-downloader').Info[]>} + */ async getTermAudioInfoList(source, term, reading) { const handler = this._getInfoHandlers.get(source.type); if (typeof handler === 'function') { @@ -50,6 +64,14 @@ export class AudioDownloader { return []; } + /** + * @param {import('audio').AudioSourceInfo[]} sources + * @param {?number} preferredAudioIndex + * @param {string} term + * @param {string} reading + * @param {?number} idleTimeout + * @returns {Promise<import('audio-downloader').AudioBinaryBase64>} + */ async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout) { const errors = []; for (const source of sources) { @@ -70,28 +92,34 @@ export class AudioDownloader { } } - const error = new Error('Could not download audio'); + const error = new ExtensionError('Could not download audio'); error.data = {errors}; throw error; } // Private + /** + * @param {string} url + * @param {string} base + * @returns {string} + */ _normalizeUrl(url, base) { return new URL(url, base).href; } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJpod101(term, reading) { if (reading === term && this._japaneseUtil.isStringEntirelyKana(term)) { reading = term; - term = null; + term = ''; } const params = new URLSearchParams(); - if (term) { + if (term.length > 0) { params.set('kanji', term); } - if (reading) { + if (reading.length > 0) { params.set('kana', reading); } @@ -99,6 +127,7 @@ export class AudioDownloader { return [{type: 'url', url}]; } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJpod101Alternate(term, reading) { const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; const data = new URLSearchParams({ @@ -149,6 +178,7 @@ export class AudioDownloader { throw new Error('Failed to find audio URL'); } + /** @type {import('audio-downloader').GetInfoHandler} */ async _getInfoJisho(term, reading) { const fetchUrl = `https://jisho.org/search/${term}`; const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { @@ -181,26 +211,52 @@ export class AudioDownloader { throw new Error('Failed to find audio URL'); } - async _getInfoTextToSpeech(term, reading, {voice}) { - if (!voice) { - throw new Error('No voice'); + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoTextToSpeech(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + const {voice} = details; + if (typeof voice !== 'string') { + throw new Error('Invalid voice'); } return [{type: 'tts', text: term, voice: voice}]; } - async _getInfoTextToSpeechReading(term, reading, {voice}) { - if (!voice) { - throw new Error('No voice'); + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoTextToSpeechReading(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + const {voice} = details; + if (typeof voice !== 'string') { + throw new Error('Invalid voice'); } return [{type: 'tts', text: reading, voice: voice}]; } - async _getInfoCustom(term, reading, {url}) { + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoCustom(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + let {url} = details; + if (typeof url !== 'string') { + throw new Error('Invalid url'); + } url = this._getCustomUrl(term, reading, url); return [{type: 'url', url}]; } - async _getInfoCustomJson(term, reading, {url}) { + /** @type {import('audio-downloader').GetInfoHandler} */ + async _getInfoCustomJson(term, reading, details) { + if (typeof details !== 'object' || details === null) { + throw new Error('Invalid arguments'); + } + let {url} = details; + if (typeof url !== 'string') { + throw new Error('Invalid url'); + } url = this._getCustomUrl(term, reading, url); const response = await this._requestBuilder.fetchAnonymous(url, { @@ -220,12 +276,14 @@ export class AudioDownloader { if (this._customAudioListSchema === null) { const schema = await this._getCustomAudioListSchema(); - this._customAudioListSchema = new JsonSchema(schema); + this._customAudioListSchema = new JsonSchema(/** @type {import('json-schema').Schema} */ (schema)); } this._customAudioListSchema.validate(responseJson); + /** @type {import('audio-downloader').Info[]} */ const results = []; for (const {url: url2, name} of responseJson.audioSources) { + /** @type {import('audio-downloader').Info1} */ const info = {type: 'url', url: url2}; if (typeof name === 'string') { info.name = name; } results.push(info); @@ -233,17 +291,32 @@ export class AudioDownloader { return results; } + /** + * @param {string} term + * @param {string} reading + * @param {string} url + * @returns {string} + * @throws {Error} + */ _getCustomUrl(term, reading, url) { if (typeof url !== 'string') { throw new Error('No custom URL defined'); } const data = {term, reading}; - return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[m1]}` : m0)); + return url.replace(/\{([^}]*)\}/g, (m0, m1) => (Object.prototype.hasOwnProperty.call(data, m1) ? `${data[/** @type {'term'|'reading'} */ (m1)]}` : m0)); } + /** + * @param {string} url + * @param {import('settings').AudioSourceType} sourceType + * @param {?number} idleTimeout + * @returns {Promise<import('audio-downloader').AudioBinaryBase64>} + */ async _downloadAudioFromUrl(url, sourceType, idleTimeout) { let signal; + /** @type {?import('request-builder.js').ProgressCallback} */ let onProgress = null; + /** @type {?import('core').Timeout} */ let idleTimer = null; if (typeof idleTimeout === 'number') { const abortController = new AbortController(); @@ -252,7 +325,9 @@ export class AudioDownloader { abortController.abort('Idle timeout'); }; onProgress = (done) => { - clearTimeout(idleTimer); + if (idleTimer !== null) { + clearTimeout(idleTimer); + } idleTimer = done ? null : setTimeout(onIdleTimeout, idleTimeout); }; idleTimer = setTimeout(onIdleTimeout, idleTimeout); @@ -287,6 +362,11 @@ export class AudioDownloader { return {data, contentType}; } + /** + * @param {ArrayBuffer} arrayBuffer + * @param {import('settings').AudioSourceType} sourceType + * @returns {Promise<boolean>} + */ async _isAudioBinaryValid(arrayBuffer, sourceType) { switch (sourceType) { case 'jpod101': @@ -304,6 +384,10 @@ export class AudioDownloader { } } + /** + * @param {ArrayBuffer} arrayBuffer + * @returns {Promise<string>} + */ async _arrayBufferDigest(arrayBuffer) { const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer))); let digest = ''; @@ -313,6 +397,11 @@ export class AudioDownloader { return digest; } + /** + * @param {string} content + * @returns {import('simple-dom-parser').ISimpleDomParser} + * @throws {Error} + */ _createSimpleDOMParser(content) { if (typeof NativeSimpleDOMParser !== 'undefined' && NativeSimpleDOMParser.isSupported()) { return new NativeSimpleDOMParser(content); @@ -323,6 +412,9 @@ export class AudioDownloader { } } + /** + * @returns {Promise<unknown>} + */ async _getCustomAudioListSchema() { const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json'); const response = await fetch(url, { |