sc-voice
Version:
SuttaCentral Voice
1,270 lines (1,213 loc) • 65.4 kB
JavaScript
(function(exports) {
const fs = require('fs');
const path = require('path');
const { exec, execSync } = require('child_process');
const URL = require('url');
const http = require('http');
const https = require('https');
const jwt = require('jsonwebtoken');
const tmp = require('tmp');
const { js } = require('just-simple').JustSimple;
const {
English,
Pali,
} = require("scv-bilara");
const {
RestBundle,
UserStore,
} = require('rest-bundle');
const { logger } = require('log-instance');
const srcPkg = require("../../package.json");
const AudioUrls = require('./audio-urls');
const ContentUpdater = require('./content-updater');
const { FilePruner } = require('memo-again');
const { GuidStore } = require('memo-again');
const MdAria = require('./md-aria');
const Playlist = require('./playlist');
const S3Bucket = require('./s3-bucket');
const { MerkleJson } = require('merkle-json');
const S3Creds = require('./s3-creds');
const SCAudio = require('./sc-audio');
const Section = require('./section');
const SoundStore = require('./sound-store');
const {
ScApi,
SuttaCentralId,
} = require('suttacentral-api');
const SuttaFactory = require('./sutta-factory');
const Sutta = require('./sutta');
const SuttaStore = require('./sutta-store');
const Task = require('./task');
const VoiceFactory = require('./voice-factory');
const Voice = require('./voice');
const VsmStore = require('./vsm-store');
const Words = require('./words');
const LANG_MAP = {
ja: 'jpn',
};
const LOCAL = path.join(__dirname, '../../local');
const PATH_SOUNDS = path.join(LOCAL, 'sounds/');
const DEFAULT_USER = {
username: "admin",
isAdmin: true,
credentials: '{"hash":"13YYGuRGjiQad/G1+MOOmxmLC/1znGYBcHWh2vUgkdq7kzTAZ6dk76S3zpP0OwZq1eofgUUJ2kq45+TxOx5tvvag","salt":"Qf1NbN3Jblo8sCL9bo32yFmwiApHSeRkr3QOJZu3KJ0Q8hbWMXAaHdoQLUWceW83tOS0jN4tuUXqWQWCH2lNCx0S","keyLength":66,"hashMethod":"pbkdf2","iterations":748406}',
};
const JWT_SECRET = `JWT${Math.random()}`;
const APP_NAME = 'scv'; // DO NOT CHANGE THIS
var fwsEn;
class ScvRest extends RestBundle {
constructor(opts = {
audioFormat: 'mp3',
}) {
super(APP_NAME, Object.assign({
srcPkg,
}, opts));
(opts.logger || logger).logInstance(this);
this.info(`ScvRest.ctor(${this.name})`);
this.wikiUrl = opts.wikiUrl
|| 'https://github.com/sc-voice/sc-voice/wiki';
this.wikiUrl = opts.wikiUrl
|| 'https://raw.githubusercontent.com/wiki/sc-voice/sc-voice';
this.examples = opts.examples;
var soundStore = this.soundStore
= opts.soundStore || new SoundStore(opts);
this.audioMIME = this.soundStore.audioMIME;
this.audioUrls = opts.audioUrls || new AudioUrls();
var scAudio = this.scAudio
= opts.scAudio || new SCAudio();
this.voiceFactory = opts.voiceFactory || new VoiceFactory({
scAudio,
soundStore,
});
this.scApi = opts.scApi || new ScApi({
logger: this,
});
this.suttaFactory = new SuttaFactory({
scApi: this.scApi,
autoSection: true,
});
this.vsmFactoryTask = new Task({
name: 'VSMFactory',
});
this.updateContentTask = new Task({
name: 'ContentUpdater',
});
this.userStore = opts.userStore || new UserStore({
defaultUser: DEFAULT_USER,
});
this.mdAria = opts.mdAria || new MdAria();
this.jwtExpires = opts.jwtExpires || '1h';
this.suttaStore = new SuttaStore({
scApi: this.scApi,
suttaFactory: this.suttaFactory,
voice: null,
});
this.downloadMap = {};
English.wordSet().then(fws=>(fwsEn = fws));
this.mj = new MerkleJson();
this.bilaraData = this.suttaStore.bilaraData;
var handlers = [
["get", "audio/:guid", this.getAudio, this.audioMIME],
["get", "audio/:guid/:filename", this.getAudio,
this.audioMIME],
["get", "audio/:sutta_uid/:lang/:translator/:voice/:guid",
this.getAudio, this.audioMIME],
["get", "recite/section/"+
":sutta_uid/:language/:translator/:iSection",
this.getReciteSection],
["get", "review/section/"+
":sutta_uid/:language/:translator/:iSection",
this.getReviewSection],
["get", "play/word/:langTrans/:vname/:word",
this.getPlayWord],
["get", "play/segment/"+
":sutta_uid/:langTrans/:translator/:scid/:vnameTrans",
this.getPlaySegment],
["get", "play/segment/:sutta_uid/"+
":langTrans/:translator/:scid/:vnameTrans/:vnameRoot",
this.getPlaySegment],
["get", "play/section/:sutta_uid/"+
":langTrans/:translator/:iSection/:vnameTrans",
this.getPlaySection],
["get", "play/section/:sutta_uid/:langTrans/"+
":translator/:iSection/:vnameTrans/:vnameRoot",
this.getPlaySection],
["get", "authors", this.getAuthors],
["get", "voices", this.getVoices],
["get", "voices/:langTrans", this.getVoices],
["get", "recite/sutta/:sutta_uid/:language/:translator",
this.getReciteSutta],
["get", "review/sutta/:sutta_uid/:language/:translator",
this.getReviewSutta],
["get", "audio-urls/:sutta_uid", this.getAudioUrls],
["get", "build-download/:type/:langs/:voice/:pattern",
this.getBuildDownload],
["get", "build-download/:type/:langs/:voice/:pattern/:vroot",
this.getBuildDownload],
["get", "download/ogg/:langs/:voice/:pattern",
this.getDownloadPlaylist, 'audio/ogg'],
["get", "download/ogg/:langs/:voice/:pattern/:vroot",
this.getDownloadPlaylist, 'audio/ogg'],
["get", "download/opus/:langs/:voice/:pattern",
this.getDownloadPlaylist, 'audio/opus'],
["get", "download/opus/:langs/:voice/:pattern/:vroot",
this.getDownloadPlaylist, 'audio/opus'],
["get", "download/mp3/:langs/:voice/:pattern",
this.getDownloadPlaylist, 'audio/opus'],
["get", "download/mp3/:langs/:voice/:pattern/:vroot",
this.getDownloadPlaylist, 'audio/mpeg'],
["get", "download/playlist/:langs/:voice/:pattern",
this.getDownloadPlaylist, this.audioMIME],
["get", "download/playlist/:langs/:voice/:pattern/:vroot",
this.getDownloadPlaylist, this.audioMIME],
["get", "sutta/:sutta_uid/:language/:translator",
this.getSutta],
["get", "search/:pattern", this.getSearch],
["get", "search/:pattern/:lang", this.getSearch],
["get", "examples/:n", this.getExamples],
["get", "wiki-aria/:page", this.getWikiAria],
["get", "debug/ephemerals", this.getDebugEphemerals],
["post", "login", this.postLogin],
["get", "bilara/:scid", this.getBilara],
["get", "auth/logs", this.getLogs],
["get", "auth/log/:ilog", this.getLog, 'text/plain'],
["get", "auth/users", this.getUsers],
["get", "auth/sound-store/volume-info",
this.getSoundStoreVolumeInfo],
["get", "auth/sound-store/pruner", this.getSoundPruner],
["post", "auth/sound-store/pruner", this.postSoundPruner],
["post", "auth/sound-store/clear-volume",
this.postSoundStoreClearVolume],
["post", "auth/delete-user", this.postDeleteUser],
["post", "auth/add-user", this.postAddUser],
["post", "auth/set-password", this.postSetPassword],
["get", "auth/vsm/list-objects", this.getVsmListObjects],
["get", "auth/vsm/s3-credentials",
this.getVsmS3Credentials],
["get", "auth/vsm/factory-task", this.getVsmFactoryTask],
["post", "auth/vsm/s3-credentials",
this.postVsmS3Credentials],
["post", "auth/vsm/create-archive",
this.postVsmCreateArchive],
["post", "auth/vsm/restore-s3-archives",
this.postVsmRestoreS3Archives],
["post", "auth/update-bilara", this.postUpdateBilara],
["post", "auth/update-content", this.postUpdateContent],
["get", "auth/update-content/task",
this.getUpdateContentTask],
["post", "auth/reboot", this.postReboot],
["post", "auth/update-release", this.postUpdateRelease],
["get", "auth/audio-info/:volume/:guid", this.getAudioInfo],
].map(h => this.resourceMethod.apply(this, h));
Object.defineProperty(this, "handlers", {
value: super.handlers.concat(handlers),
});
}
async initialize() { try {
this.info(`ScvRest initialize() BEGIN`);
var superInit = super.initialize;
await this.scApi.initialize();
await this.suttaFactory.initialize();
await this.suttaStore.initialize();
this.voices = Voice.loadVoices();
var result = await superInit.call(this);
this.info(`ScvRest initialize() COMPLETED`);
return result;
} catch(e) {
this.warn(e);
throw e;
}}
async getAudio(req, res, next) { try {
var guid = req.params.guid;
var {
sutta_uid,
lang,
translator,
voice,
} = req.params;
var volume = !sutta_uid || sutta_uid === 'word'
? 'play-word'
: SoundStore.suttaVolumeName(sutta_uid,
lang, translator, voice);
var soundOpts = { volume };
var filePath = this.soundStore.guidPath(guid, soundOpts);
var filename = req.params.filename;
var data = fs.readFileSync(filePath);
res.set('accept-ranges', 'bytes');
res.set('do_stream', 'true');
filename && res.set('Content-disposition',
'attachment; filename=' + filename);
return data;
} catch (e) {
this.warn(e);
throw e;
}}
async getAudioInfo(req, res, next) { try {
var {
guid,
volume,
} = req.params;
this.info([
`getAudioInfo()`,
js.simpleString(req.params),
].join(' '));
var info = this.soundStore.soundInfo({guid, volume});
return info;
} catch (e) {
this.warn(e);
throw e;
}}
suttaParms(req) {
var parms = Object.assign({
language: 'en', //deprecated
voicename: 'amy',
vnameTrans: 'Amy',
vnameRoot: 'Aditi',
usage: 'recite',
iSection: 0,
scid: null,
iVoice: 0,
}, req.params);
parms.iSection = Number(parms.iSection);
parms.iVoice = Number(parms.iVoice);
parms.sutta_uid = parms.sutta_uid || parms.scid && parms.scid.split(':')[0];
parms.langTrans = parms.langTrans || parms.language || 'en';
return parms;
}
async reciteSection(req, res, next, usage) { try {
var { sutta_uid, language, translator, iSection } = this.suttaParms(req);
var sutta = await this.suttaFactory.loadSutta({
scid: sutta_uid,
translator,
language,
expand: true,
});
if (iSection < 0 || sutta.sections.length <= iSection) {
var suttaRef = `${sutta_uid}/${language}/${translator}`;
throw new Error(`Sutta ${suttaRef} has no section:${iSection}`);
}
var lines = Sutta.textOfSegments(sutta.sections[iSection].segments);
var text = `${lines.join('\n')}\n`;
var voice = Voice.createVoice({
language,
usage,
soundStore: this.soundStore,
localeIPA: "pli",
audioFormat: this.soundStore.audioFormat,
audioSuffix: this.soundStore.audioSuffix,
});
var result = await voice.speak(text, {
cache: true, // false: use TTS web service for every request
usage,
});
return {
usage,
name: voice.name,
sutta_uid,
language,
translator,
section:iSection,
guid: result.signature.guid,
}
} catch(e) {
this.warn(e);
throw e;
}}
async synthesizeSutta(sutta_uid, language, translator, usage) { try {
var sutta = await this.suttaFactory.loadSutta({
scid: sutta_uid,
translator,
language,
expand: true,
});
var preamble = [
`suttacentral voice recording\n`,
`{${sutta.suttaCode}}\n`,
].join('');
var lines = Sutta.textOfSegments(sutta.segments);
var text = `${preamble}\n${lines.join('\n')}\n`;
var voice = Voice.createVoice({
language,
usage,
soundStore: this.soundStore,
localeIPA: "pli",
audioFormat: this.soundStore.audioFormat,
audioSuffix: this.soundStore.audioSuffix,
});
var msStart = Date.now();
if (lines.length > 750) {
this.info(`synthesizeSutta()`+
`lines:${lines.length} text:${text.length*2}B`);
}
var result = await voice.speak(text, {
cache: true, // minimize TTS web service use
usage,
});
this.info(
`synthesizeSutta() ms:${Date.now()-msStart} `+
`${text.substring(0,50)}`);
return {
usage,
name: voice.name,
sutta_uid,
language,
translator,
guid: result.signature.guid,
};
} catch(e) {
this.warn(e);
throw e;
}}
getReciteSection(req, res, next) {
var promise = this.reciteSection(req, res, next, 'recite');
promise.catch(e => {
console.error(e.stack);
});
return promise;
}
getReviewSection(req, res, next) {
return this.reciteSection(req, res, next, 'review');
}
getAuthors(req, res, next) {
return Promise.resolve(this.bilaraData.authors);
}
async getVoices(req, res, next) { try {
var {
langTrans,
} = req.params;
var voices = this.voices.slice();
if (!!langTrans) {
voices = voices.filter(v =>
v.langTrans === 'pli' || v.langTrans===langTrans);
}
voices.sort(Voice.compare);
return voices;
} catch (e) {
this.warn(e);
throw e;
}}
async getPlaySection(req, res, next) { try {
var {
sutta_uid, translator, iSection,
vnameRoot, vnameTrans,
langTrans,
} = this.suttaParms(req);
var suttaRef = `${sutta_uid}/${langTrans}/${translator}`;
this.info(
`GET play/section/${suttaRef}/${iSection}/${vnameTrans}`);
var voiceTrans = Voice.voiceOfName(vnameTrans) ||
Voice.voiceOfName('Amy');
var usage = voiceTrans.usage;
var voiceRoot = this.voiceFactory.voiceOfName('Aditi');
var sutta = await this.suttaStore.loadSutta({
scid: sutta_uid,
translator,
language: langTrans,
expand: true,
});
var nSects = sutta && sutta.sections.length || 0;
if (iSection < 0 || nSects <= iSection) {
throw new Error(`Sutta ${suttaRef} `+
`has no section:${iSection}`);
}
var section = sutta.sections[iSection];
var segments = [];
var pali = new Pali();
var sectSegs = section.segments;
for (var iSeg = 0; iSeg < sectSegs.length; iSeg++) {
var segment = Object.assign({}, sectSegs[iSeg]);
segment.audio = {};
segments.push(segment);
}
return {
sutta_uid,
language: langTrans,
translator,
title: section.title,
section:iSection,
nSections: sutta.sections.length,
segments,
vnameTrans: voiceTrans.name,
vnameRoot: voiceRoot.name,
};
} catch(e) {
this.warn(e);
throw e;
}}
getPlaySegment(req, res, next) {
var that = this;
var {
sutta_uid, langTrans, translator, scid,
vnameTrans, vnameRoot,
} = this.suttaParms(req);
if (/[0-9]+/.test(vnameTrans)) {
var iVoice = Number(vnameTrans);
}
var scAudio = this.scAudio;
var voice = Voice.voiceOfName(vnameTrans);
var voiceRoot = this.voiceFactory.voiceOfName(vnameRoot);
that.debug(`GET ${req.url}`);
var usage = voice.usage || 'recite';
var pbody = (resolve, reject) => {(async function(){ try {
var sutta = await that.suttaStore.loadSutta({
scid: sutta_uid,
translator,
language: langTrans, // deprecated
langTrans,
expand: true,
});
if (iSection < 0 || sutta.sections.length <= iSection) {
var suttaRef =
`${sutta_uid}/${langTrans}/${translator}`;
throw new Error(
`Sutta ${suttaRef} has no section:${iSection}`);
}
var voiceTrans = Voice.createVoice({
name: voice.name,
usage,
soundStore: that.soundStore,
localeIPA: "pli",
audioFormat: that.soundStore.audioFormat,
audioSuffix: that.soundStore.audioSuffix,
scAudio,
});
var sections = sutta.sections;
var iSegment = sutta.segments
.reduce((acc,seg,i) => seg.scid == scid ? i : acc,
null);
if (iSegment == null) {
throw new Error(`segment ${scid} not found`);
}
var segment = sutta.segments[iSegment];
var iSection = 0;
var section = sutta.sections[iSection];
let nSegs = section.segments.length;
for (let i=iSegment; section && (nSegs.length <= i); ) {
i -= section.segments.length;
section = sutta.sections[++iSection];
}
segment.audio = {};
if (segment[langTrans]) {
var resSpeak = await voiceTrans.speakSegment({
sutta_uid,
segment,
language: langTrans,
translator,
usage,
});
segment.audio[langTrans] = resSpeak.signature.guid;
segment.audio.vnameTrans = resSpeak.altTts;
}
if (segment.pli) {
var pali = new Pali();
var resSpeak = await voiceRoot.speakSegment({
sutta_uid,
segment,
language: 'pli',
translator,
usage: 'recite',
});
segment.audio.pli = resSpeak.signature.guid;
segment.audio.vnamePali = resSpeak.altTts;
}
var audio = segment.audio;
that.info(`GET ${req.url} =>`,
audio[langTrans] ? `${langTrans}:${audio[langTrans]}` : ``,
audio.pli ? `pli:${audio.pli}` : ``,
);
resolve({
sutta_uid,
scid,
language: langTrans, // deprecated
langTrans,
translator,
title: section.title,
section:iSection,
nSections: sutta.sections.length,
vnameTrans: voiceTrans.name,
vnameRoot,
iSegment,
segment,
});
} catch(e) {
that.warn(`GET ${req.url} => `, e.message);
reject(e);
} })(); }
return new Promise(pbody);
}
getPlayWord(req, res, next) {
var that = this;
var {
langTrans, word, vname,
} = this.suttaParms(req);
if (/[0-9]+/.test(vname)) {
var iVoice = Number(vname);
}
if (langTrans !== 'pli') {
return Promise.resolve(new Error(
`Only Pali words can be spoken individually`));
}
if (vname !== 'Aditi') {
return Promise.resolve(new Error(
`Only Aditi can speak Pali words`));
}
if (!word) {
return Promise.resolve(new Error(
`No word given to speak`));
}
var scAudio = this.scAudio;
var voice = Voice.voiceOfName(vname);
var voiceRoot = this.voiceFactory.voiceOfName(vname);
that.info(`GET ${req.url}`);
var usage = voice.usage || 'recite';
var pbody = (resolve, reject) => {(async function(){ try {
var voiceTrans = Voice.createVoice({
name: voice.name,
usage,
soundStore: that.soundStore,
localeIPA: "pli",
audioFormat: that.soundStore.audioFormat,
audioSuffix: that.soundStore.audioSuffix,
scAudio,
fwsEn,
});
var volume = "play-word";
var translator = 'ms';
var resSpeak = await voiceTrans.speak(word, {
language: langTrans,
translator,
usage,
volume,
});
var {
hits,
misses,
signature,
} = resSpeak || {};
resolve({
word,
langTrans,
voice: voice.name,
hits,
misses,
signature,
});
} catch(e) { reject(e); } })(); }
return new Promise(pbody);
}
getReciteSutta(req, res, next) {
var { sutta_uid, language, translator } = this.suttaParms(req);
return this.synthesizeSutta(sutta_uid, language, translator, 'recite');
}
getReviewSutta(req, res, next) {
var { sutta_uid, language, translator } = this.suttaParms(req);
return this.synthesizeSutta(sutta_uid, language, translator, 'review');
}
getSutta(req, res, next) {
var that = this;
var language = req.params.language || 'en';
var sutta_uid = req.params.sutta_uid || 'mn1';
var translator = req.params.translator || 'sujato';
var iSection = Number(
req.params.iSection == null ? 0 : req.params.iSection);
return new Promise((resolve, reject) => {
(async function() { try {
var sutta = await that.suttaFactory.loadSutta({
scid: sutta_uid,
translator,
language,
expand: true,
});
var blurb = await that.bilaraData.readBlurb({
suid: sutta_uid,
lang: language,
});
sutta.blurb = blurb;
that.info(`GET sutta`,
`=> ${sutta_uid}/${language}/${translator}`);
resolve(sutta);
} catch(e) { reject(e); } })();
});
}
async getSearch(req, res, next) { try {
var language = req.params.lang || 'en';
LANG_MAP[language] && (language = LANG_MAP[language]);
var pattern = req.params.pattern;
if (!pattern) {
throw new Error('Search pattern is required');
}
var maxResults = Number(req.query.maxResults ||
this.suttaStore.maxResults);
if (isNaN(maxResults)) {
throw new Error('Expected number for maxResults');
}
var srOpts = {
pattern,
language,
maxResults,
};
var sr = await this.suttaStore.search(srOpts);
var {
method,
results,
mlDocs,
} = sr;
this.info( `GET search(${pattern}) ${language} ${method}`,
`=> ${results.map(r=>r.uid)}`,);
return sr;
} catch(e) {
this.warn(`getSearch(${JSON.stringify(srOpts)})`, e.message);
throw e;
}}
async buildDownload(args) { try {
var {
soundStore,
suttaStore,
voiceFactory,
bilaraData,
} = this;
var {
audioSuffix,
vroot,
langs,
language,
vname,
pattern,
maxResults,
} = args;
if (isNaN(maxResults)) {
throw new Error('Expected number for maxResults');
}
try {
var playlist = await suttaStore.createPlaylist({
pattern,
languages: langs,
language,
maxResults,
audioSuffix,
});
} catch(e) {
console.log(`oopsie`, e.stack);
return e.stack.toString();
}
var voiceLang = voiceFactory.voiceOfName(vname);
var voiceRoot = voiceFactory.voiceOfName(vroot);
let voices = langs.map(l=>{
return l==='pli'
? voiceRoot.name
: voiceLang.name;
});
let artists = playlist.author_uids()
.map(a=> {
let ai = bilaraData.authorInfo(a);
return ai ? ai.name : a;
}).concat(voices);
let artist = artists.join(', ');
var stats = playlist.stats();
let buildDate = new Date();
let yyyy = buildDate.toLocaleString(undefined, {
year: 'numeric',
});;
let mm = buildDate.toLocaleString(undefined, {
month: '2-digit',
});;
let album = `${yyyy}-${mm} voice.suttacentral.net`;
var audio = await playlist.speak({
voices: {
pli: voiceRoot,
[language]: voiceLang,
},
album,
artist,
album_artist: artist,
languages: langs.join(','),
audioSuffix,
copyright: 'https://suttacentral.net/licensing',
publisher: 'voice.suttacentral.net',
title: pattern,
});
var result = {
audio,
}
var guid = audio.signature.guid;
var filepath = soundStore.guidPath(guid, audioSuffix);
var uriPattern = encodeURIComponent(
decodeURIComponent(pattern)
.replace(/[ ,\t]/g,'_')
.replace(/[\/]/g, '-')
);
var filename = `${uriPattern}_${langs.join('+')}_${vname}${audioSuffix}`;
return {
filepath,
filename,
guid,
stats,
buildDate,
}
} catch(e) {
this.warn(`buildDownload()`, JSON.stringify(args, null, 2),
e.message);
throw e;
}}
async getBuildDownload(req, res, next) { try {
var {
initialized,
soundStore,
suttaStore,
mj,
downloadMap,
} = this;
if (!initialized) {
throw new Error(`${this.constructor.name} is not initialized`);
}
var type = req.params.type || 'unknown-type';
let audioSuffix = soundStore.audioSuffix;
audioSuffix = type==='opus' ? '.opus' : audioSuffix;
audioSuffix = type==='ogg' ? '.ogg' : audioSuffix;
audioSuffix = type==='mp3' ? '.mp3' : audioSuffix;
var vroot = req.params.vroot || 'Aditi';
var langs = (req.params.langs || 'pli+en')
.toLowerCase().split('+')
.map(l => LANG_MAP[l] || l);
var language = langs.filter(l=>l!=='pli')[0];
var language = language ||
LANG_MAP[req.query.lang] || reg.query.lang ||
'en';
var vname = (req.params.voice || 'Amy').toLowerCase();
var pattern = req.params.pattern;
if (!pattern) {
throw new Error('Search pattern is required');
}
var maxResults =
Number(req.query.maxResults || suttaStore.maxResults);
let downloadArgs = {
audioSuffix,
vroot,
langs,
language,
vname,
pattern,
maxResults,
};
let hash = mj.hash(downloadArgs);
let value = downloadMap[hash];
if (value && !(value instanceof Promise)) {
if (!fs.existsSync(value.filepath)) {
// downloadMap is stale.
downloadMap[hash] = value = null;
}
}
if (value == null) {
this.info(`buildDownload(${hash}) started`);
value = this.buildDownload(downloadArgs);
downloadMap[hash] = value;
value.then(v=>{
downloadMap[hash] = v;
this.info(`buildDownload(${hash}) ok:`, JSON.stringify(v));
});
}
if (value && !(value instanceof Promise)) {
downloadArgs.filename = value.filename;
downloadArgs.guid = value.guid;
}
return downloadArgs;
} catch(e) {
this.warn(`getBuildDownload`, JSON.stringify({}),
e.message);
throw e;
}}
async getDownloadPlaylist(req, res, next) { try {
var {
initialized,
soundStore,
suttaStore,
mj,
downloadMap,
} = this;
if (!initialized) {
throw new Error(`${this.constructor.name} is not initialized`);
}
let route = req.route.path.split('/');
let audioSuffix = soundStore.audioSuffix;
audioSuffix = route[2]==='opus' ? '.opus' : audioSuffix;
audioSuffix = route[2]==='ogg' ? '.ogg' : audioSuffix;
var vroot = req.params.vroot || 'Aditi';
var langs = (req.params.langs || 'pli+en')
.toLowerCase().split('+')
.map(l => LANG_MAP[l] || l);
var language = langs.filter(l=>l!=='pli')[0];
var language = language ||
LANG_MAP[req.query.lang] || reg.query.lang ||
'en';
var vname = (req.params.voice || 'Amy').toLowerCase();
var pattern = req.params.pattern;
if (!pattern) {
throw new Error('Search pattern is required');
}
var maxResults =
Number(req.query.maxResults || suttaStore.maxResults);
let downloadArgs = {
audioSuffix,
vroot,
langs,
language,
vname,
pattern,
maxResults,
};
let hash = mj.hash(downloadArgs);
let value = downloadMap[hash];
if (!value || value instanceof Promise) {
value = await this.buildDownload(downloadArgs);
}
let {
filepath,
filename,
guid,
stats,
} = value;
var data = await fs.promises.readFile(filepath);
res.set('Content-disposition', 'attachment; filename=' + filename);
this.info(`GET download/${langs}/${pattern} => ` +
`${filename} size:${data.length} `+
`secs:${stats.duration} ${guid}`);
let date = new Date();
res.cookie('download-date', date.toISOString()); // DEPRECATED
return data;
} catch(e) {
this.warn(`getDownloadPlaylist`, JSON.stringify({}),
e.message);
throw e;
}}
getExamples(req, res, next) {
var that = this;
let { bilaraData } = this;
let lang = req.query.lang;
lang = LANG_MAP[lang] || lang || 'en';
var n = Number(req.params.n);
let langExamples = bilaraData.examples[lang] || bilaraData.examples.en;
var nShuffle = langExamples.length;
for (var i = 0; i < nShuffle; i++) {
var j = Math.trunc(Math.random() * langExamples.length);
var t = langExamples[i];
langExamples[i] = langExamples[j];
langExamples[j] = t;
}
return Number.isInteger(n) && n > 0
? langExamples.slice(0, n)
: langExamples.sort();
}
getWikiAria(req, res, next) {
var that = this;
var page = req.params.page || 'Home.md';
return new Promise((resolve, reject) => {
(async function() { try {
var result = `${page} not found`;
var wikiUrl = `${that.wikiUrl}/${page}`;
var httpx = wikiUrl.startsWith('https') ? https : http;
var urlObj = URL.parse(wikiUrl);
var httpOpts = Object.assign({
headers: {
"Cache-Control": "no-cache",
//"Pragma": "no-cache",
},
}, urlObj);
var wikiReq = httpx.get(httpOpts, function(wikiRes) {
const { statusCode } = wikiRes;
const contentType = wikiRes.headers['content-type'];
let error;
let okStatus = {
200: true,
302: true,
304: true,
};
if (!okStatus[statusCode]) {
error = new Error(
`Request failed for ${wikiUrl}\n` +
`Status Code: ${statusCode}`);
} else if (/^text\/html/.test(contentType)) {
// OK
} else if (/^text\/plain/.test(contentType)) {
// OK
} else {
error = new Error('Invalid content-type.\n' +
`Expected application/json but received ${contentType}`);
}
if (error) {
wikiRes.resume(); // consume response data to free up memory
that.warn(error.stack);
reject(error);
return;
}
wikiRes.setEncoding('utf8');
let rawData = '';
wikiRes.on('data', (chunk) => { rawData += chunk; });
wikiRes.on('end', () => {
try {
var md = rawData.toString();
var html = that.mdAria.toHtml(md);
resolve({
url: wikiUrl,
html:html,
});
} catch (e) {
that.warn(e.stack);
reject(e);
}
});
}).on('error', (e) => {
that.warn(e.stack);
reject(e);
}).on('timeout', (e) => {
that.warn(e);
wikiReq.abort();
});
} catch(e) { reject(e); } })();
});
}
getDebugEphemerals(req, res, next) {
return {
ephemeralAge: this.soundStore.ephemeralAge,
ephemeralInterval: this.soundStore.ephemeralInterval,
ephemerals: this.soundStore.ephemerals,
}
}
postLogin(req, res, next) {
var that = this;
var us = that.userStore;
var {
username,
password,
} = req.body || {};
return new Promise((resolve, reject) => {
(async function() { try {
var authuser = await us.authenticate(username, password);
if (authuser == null) {
res.locals.status = 401;
that.warn(`POST login ${username} => HTTP401 UNAUTHORIZED`);
throw new Error('Invalid username/password');
}
delete authuser.credentials;
that.info(`POST login ${username} => ${JSON.stringify(authuser)}`);
var token = jwt.sign(authuser, JWT_SECRET, {
expiresIn: that.jwtExpires,
});
authuser.token = token;
resolve(authuser);
} catch(e) {reject(e);} })();
});
}
getUsers(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
var decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
var users = await that.userStore.users();
if (!decoded.isAdmin) {
users = {
[decoded.username]: users[decoded.username],
};
}
resolve(users);
} catch(e) {reject(e);} })();
});
}
getLogs(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
var logDir = path.join(LOCAL, 'logs');
fs.readdir(logDir,null,(err, files) => {
if (err) {
reject(err);
} else {
files.sort((a,b)=>-a.localeCompare(b));
resolve(files.map(f => {
var fpath = path.join(logDir, f);
var stats = fs.statSync(fpath);
return {
name: f,
size: stats.size,
mtime: stats.mtime,
ctime: stats.ctime,
}
}));
//resolve(files.sort((a,b)=>-a.localeCompare(b)));
}
});
} catch(e) {reject(e);} })();
});
}
getLog(req, res, next) {
var that = this;
var {
ilog,
} = req.params;
if (ilog == null) {
ilog = 0;
}
return new Promise((resolve, reject) => {
(async function() { try {
var logDir = path.join(LOCAL, 'logs');
fs.readdir(logDir,null,(err, files) => {
if (err) {
reject(err);
} else {
files.sort((a,b)=>-a.localeCompare(b));
var file = files[ilog];
if (file) {
file = path.join(logDir, file);
fs.readFile(file, (err, data) => {
if (err) {
reject(err);
} else {
res.set('Content-Type', 'text/plain');
resolve(data);
}
});
} else {
reject(new Error(
`Log file not found:${ilog}`));
}
}
});
} catch(e) {reject(e);} })();
});
}
postDeleteUser(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
var {
username,
} = req.body || {};
var decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
if (decoded.username !== username && !decoded.isAdmin) {
throw new Error('Unauthorized user deletion'+
`of:${username} by:${decoded.username}`);
}
var result = await that.userStore.deleteUser(username);
that.info(`POST delete-user `+
`user:${username} by:${decoded.username} => OK`);
resolve(result);
} catch(e) {reject(e);} })();
});
}
postAddUser(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
var user = {
username: req.body.username,
password: req.body.password,
isAdmin: req.body.isAdmin,
isTranslator: req.body.isTranslator,
isEditor: req.body.isEditor,
};
var decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
var result = await that.userStore.addUser(user);
that.info(`POST add-user `+
`user:${user.username} by:${decoded.username} => OK`);
resolve(result);
} catch(e) {reject(e);} })();
});
}
postSetPassword(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
var {
username,
password,
} = req.body || {};
var decoded = jwt.decode(req.headers.authorization.split(' ')[1]);
var result = await that.userStore.setPassword(username, password);
that.info(`POST set-password `+
`for:${username} by:${decoded.username} => OK`);
resolve(result);
} catch(e) {reject(e);} })();
});
}
static get JWT_SECRET() {
return JWT_SECRET;
}
requireAdmin(req, res, msg){
var that = this;
var authorization = req.headers.authorization || "";
var decoded = jwt.decode(authorization.split(' ')[1]);
var {
username,
} = decoded;
if (decoded.isAdmin) {
that.info(`${msg}:${username} => AUTHORIZED`);
} else {
res.locals.status = 401;
that.warn(`${msg}:${username} => HTTP401 UNAUTHORIZED (ADMIN)`);
throw new Error('Admin privilege required');
}
return true;
}
getSoundStoreVolumeInfo(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
that.requireAdmin(req, res, "GET sound-store/volume-info");
resolve(that.soundStore.volumeInfo());
} catch(e) {reject(e);} })();
});
}
async getSoundPruner(req, res, next) {
this.requireAdmin(req, res, "GET sound-store/pruner");
var {
done,
earliest,
pruneDays,
pruning,
bytesScanned,
bytesPruned,
filesPruned,
started,
} = this.soundStore.filePruner;
return {
done,
earliest,
pruneDays,
pruning,
bytesScanned,
bytesPruned,
filesPruned,
started,
};
}
postSoundPruner(req, res, next) {
var filePruner = this.soundStore.filePruner;
this.requireAdmin(req, res, "POST sound-store/prune");
filePruner.pruneDays = req.body.pruneDays;
filePruner.pruneOldFiles().then(res=>{
let {
started,
earliest,
bytesScanned,
bytesPruned,
filesPruned,
} = res;
this.info(`pruneOldFiles()`, res);
});
return this.getSoundPruner(req, res, next);
}
postSoundStoreClearVolume(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {
(async function() { try {
that.requireAdmin(req, res, "POST sound-store/clear-volume");
var {
volume,
} = req.body || {};
var result = await that.soundStore.clearVolume(volume);
resolve(result);
} catch(e) {reject(e);} })();
});
}
postReboot(req, res, next) {
var that = this;
return new Promise((resolve, reject) => {