├── .env.sample ├── .eslintrc.js ├── .gitignore ├── README.md ├── app.js ├── constants ├── constants.js └── ytdlpUsers.txt ├── docs ├── install-libretranslate ├── install.sh ├── libretranslate-nginx ├── nginx-whisper-and-libretranslate.conf ├── readme.md └── thing.conf ├── downloading ├── download.js └── yt-dlp-download.js ├── examples └── dnevnik.srt ├── helpers ├── formatStdErr.js ├── getLanguageNames.js └── helpers.js ├── lib ├── convertText.js ├── files.js ├── stats.js ├── transcribing.js └── websockets.js ├── package-lock.json ├── package.json ├── public ├── images │ ├── Octocat.png │ ├── discordLogo.png │ ├── favicon.ico │ ├── gh-logo.png │ ├── githublogo.png │ ├── inverted.png │ ├── telegramLogo.webp │ ├── thing.png │ ├── transcriptionIcon.png │ └── twitterLogo.png ├── javascripts │ └── circle-progress.min.js ├── robots.txt └── stylesheets │ └── style.css ├── queue ├── newQueue.js └── queue.js ├── routes ├── admin.js ├── api.js ├── index.js ├── player.js ├── stats.js ├── transcribe.js └── users.js ├── scripts ├── deleteTranscriptionUploads.js ├── extractAudioFfmpeg.js ├── postAudioFile.js └── srtToVtt.js ├── transcribe ├── transcribe-api-wrapped.js ├── transcribe-via-api.js ├── transcribe-wrapped.js └── transcribing.js ├── translate ├── create-translated-files.js ├── google-translate-browser.js ├── helpers.js ├── libreTranslateWrapper.js └── translate-files-api.js └── views ├── addTranslation └── addTranslation.pug ├── admin.pug ├── error.pug ├── files.pug ├── index ├── components │ ├── amounts-header.pug │ ├── social-buttons.pug │ ├── transcription-results.pug │ └── upload-form.pug ├── index.pug ├── js │ ├── controllers │ │ ├── error-handling.pug │ │ ├── file-handling.pug │ │ ├── network-handling.pug │ │ └── selection-dropdowns.pug │ ├── js-index.pug │ └── js-util.pug └── styles │ ├── styles-amounts-header.pug │ ├── styles-form.pug │ ├── styles-social.pug │ └── styles-transcription-results.pug ├── layout.pug ├── player ├── js │ ├── captionsDisplay.pug │ ├── secondCaptions.pug │ └── videoProgress.pug ├── player.pug └── styles-player.pug ├── queue.pug ├── stats └── stats.pug └── styles └── styles-global.pug /.env.sample: -------------------------------------------------------------------------------- 1 | CONCURRENT_AMOUNT=1 2 | # uncomment this if you have LibreTranslate running locally 3 | #LIBRETRANSLATE='http://127.0.0.1:5000' 4 | UPLOAD_FILE_SIZE_LIMIT_IN_MB=100 5 | MULTIPLE_GPUS=false 6 | FILES_PASSWORD=password 7 | NODE_ENV='development' 8 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | commonjs: true, 5 | es2021: true, 6 | }, 7 | overrides: [], 8 | parserOptions: { 9 | ecmaVersion: 'latest', 10 | }, 11 | ignorePatterns: ['public/*'], 12 | rules: { 13 | 'quotes': ['error', 'single'], 14 | 'space-before-blocks': ['error', 'always'], 15 | 'space-before-function-paren': ['error', 'always'], 16 | 'keyword-spacing': ['error', { 'before': true, 'after': true }], 17 | 'no-var': 'error' 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .idea 3 | assets 4 | test 5 | uploads 6 | transcriptions 7 | transcriptions1 8 | downloads 9 | 10 | public/javascripts/ffmpeg-core.wasm 11 | .env 12 | 13 | constants/apiTokens.txt 14 | 15 | api-transcriptions 16 | scripts/output-audio.aac 17 | scripts/trimmed.mp4 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # generate-subtitles 2 | 3 | Generate transcripts for audio and video content with a user friendly UI, powered by Open AI's Whisper with automatic translations powered by LibreTranslate. Live for free public use at https://freesubtitles.ai 4 | 5 | ## Installation: 6 | Under the hood, `generate-subtitles` uses Whisper AI for creating transcripts and Libretranslate for generating the translations. Libretranslate is optional and not required to run the service. 7 | 8 | You can find the installation instructions for Whisper here: https://github.com/openai/whisper#setup 9 | 10 | Once Whisper is installed and working properly, you can start the web server. 11 | 12 | Make sure you are running Node.js 14+ 13 | 14 | `nvm use 14` 15 | 16 | You can install Node 14 with `nvm`: 17 | 18 | ```shell 19 | # install nvm 20 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash 21 | 22 | # setup nvm 23 | export NVM_DIR="$HOME/.nvm" 24 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 25 | [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion 26 | 27 | nvm install 14 28 | nvm use 14 29 | ``` 30 | 31 | Currently the app uses `yt-dlp` as well, you can install it with: 32 | 33 | ```shell 34 | sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp #download yt-dlp 35 | sudo chmod a+rx /usr/local/bin/yt-dlp # Make executable 36 | ``` 37 | 38 | Then: 39 | 40 | ```shell 41 | git clone https://github.com/mayeaux/generate-subtitles 42 | cd generate-subtitles 43 | npm install 44 | npm start 45 | ``` 46 | 47 | This should start the server at localhost:3000, at which point if you navigate to there with a browser you should be able to see and use the app. 48 | 49 | ## Using a GPU Cloud Provider 50 | Note: Unless you have a GPU that can use CUDA, you will likely have to use your CPU to transcribe which is significantly less performant, hence why you may have to rent a GPU server from a cloud provider. The only GPU cloud provider that I've had a good experience with is VastAI which is what I use to run https://freesubtitles.ai , if you use this link I should receive a 2.5% of your purchase for the referral: http://vast.ai/?ref=52232 51 | 52 | To setup the Vast server to run Whisper, you can use the following script: 53 | https://github.com/mayeaux/generate-subtitles/blob/master/docs/install.sh (Note, this script isn't perfect yet but has all the ingredients you need). 54 | 55 | While creating the Vast server, you will have to open some ports, this is the configuration I use to achieve that: 56 | 57 | Hit `EDIT IMAGE & CONFIG..` 58 | 59 | Screen Shot 2022-12-14 at 3 15 48 PM 60 | 61 | 62 | I select CUDA though it's not 100% necessary 63 | 64 | Screen Shot 2022-12-14 at 3 15 58 PM 65 | 66 | Then hit the `SELECT` button (the one that's to the right of the CUDA description and not the one next to cancel) and you can add this line to open the ports: 67 | `-p 8081:8081 -p 8080:8080 -p 80:80 -p 443:443 -p 3000:3000 -p 5000:5000` 68 | 69 | Screen Shot 2022-12-14 at 3 16 22 PM 70 | 71 | Hit `SELECT & SAVE` and when you create an instance it should have the proper ports opened to be able to access the web app. Vast uses port forwarding so when your port 3000 is opened it will be accessed through another port but you should be able to figure that out from their interface. 72 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const favicon = require('serve-favicon'); 4 | const logger = require('morgan'); 5 | const cookieParser = require('cookie-parser'); 6 | const bodyParser = require('body-parser'); 7 | const { WebSocketServer } = require('ws'); 8 | const fs = require('fs'); 9 | const {createServer} = require('http'); 10 | const sessions = require('express-session'); 11 | const _ = require('lodash'); 12 | l = console.log; 13 | const { deleteOldFiles } = require('./scripts/deleteTranscriptionUploads'); 14 | 15 | // Load the .env file 16 | require('dotenv').config(); 17 | 18 | const { createWebSocketServer } = require('./lib/websockets'); 19 | 20 | l('node env'); 21 | l(process.env.NODE_ENV); 22 | 23 | // run stats gathering 24 | require('./lib/stats'); 25 | 26 | // Check if the .env file exists 27 | if (!fs.existsSync('.env')) { 28 | // If the .env file does not exist, copy the .env.sample file to .env 29 | fs.copyFileSync('.env.sample', '.env'); 30 | } 31 | 32 | const hourInMilliseconds = 1000 * 60 * 60; 33 | 34 | function runDeleteLoop () { 35 | setTimeout(() => { 36 | deleteOldFiles(true); 37 | runDeleteLoop(); 38 | }, hourInMilliseconds); // repeat every 1000 milliseconds (1 second) 39 | } 40 | 41 | if (process.env.NODE_ENV === 'production') { 42 | deleteOldFiles(true); 43 | runDeleteLoop(); 44 | } 45 | 46 | l(`FILES PASSWORD: ${process.env.FILES_PASSWORD}`); 47 | 48 | const routes = require('./routes/index'); 49 | const users = require('./routes/users'); 50 | const api = require('./routes/api'); 51 | const stats = require('./routes/stats'); 52 | const player = require('./routes/player'); 53 | const transcribe = require('./routes/transcribe'); 54 | const admin = require('./routes/admin'); 55 | 56 | const app = express(); 57 | const server = createServer(app); 58 | 59 | createWebSocketServer(server); 60 | 61 | l = console.log; 62 | 63 | // l = function(l) { 64 | // var stack = (new Error()).stack.split(/\n/); 65 | // // Chrome includes a single "Error" line, FF doesn't. 66 | // if (stack[0].indexOf('Error') === 0) { 67 | // stack = stack.slice(1); 68 | // } 69 | // var args = [].slice.apply(arguments).concat([stack[1].trim()]); 70 | // return console.log(console, args); 71 | // } 72 | const port = process.env.PORT || '3000'; 73 | app.set('port', port); 74 | 75 | // create folders if they don't exist yet 76 | // fs.mkdirSync('uploads', { recursive: true }) 77 | fs.mkdirSync('uploads', { recursive: true }) 78 | fs.mkdirSync('transcriptions', { recursive: true }) 79 | 80 | // view engine setup 81 | app.set('views', path.join(__dirname, 'views')); 82 | app.set('view engine', 'pug'); 83 | 84 | app.use(favicon(path.join(__dirname,'public','images','favicon.ico'))); 85 | app.use(logger('dev')); 86 | app.use(bodyParser.json({ limit: '1mb' })); 87 | app.use(bodyParser.urlencoded({ extended: true, limit: '1mb' })); 88 | app.use(cookieParser()); 89 | app.use(express.static(path.join(__dirname, 'public'))); 90 | // assumes nginx 91 | // if(!isProd){ 92 | // TODO: this isn't secure if the API key is there 93 | app.use(express.static(__dirname)); 94 | // } 95 | 96 | const oneWeek = 1000 * 60 * 60 * 24 * 7; 97 | 98 | //session middleware 99 | app.use(sessions({ 100 | secret: (Math.random() * 1000000000).toString(), 101 | cookie: { maxAge: oneWeek }, 102 | saveUninitialized: false, 103 | resave: false 104 | })); 105 | 106 | app.use(function (req, res, next) { 107 | const ipAddress = req.headers['x-forwarded-for'] || req.socket.remoteAddress; 108 | l('IP Address') 109 | l(ipAddress); 110 | next(); 111 | }) 112 | 113 | app.use('/', routes); 114 | app.use('/', api); 115 | app.use('/users', users); 116 | app.use('/', stats); 117 | app.use('/', transcribe); 118 | app.use('/', admin); 119 | app.use('/', player); 120 | 121 | // catch 404 and forward to error handler 122 | app.use(function (req, res, next) { 123 | let err = new Error('Not Found'); 124 | err.status = 404; 125 | next(err); 126 | }); 127 | 128 | // error handlers 129 | 130 | // development error handler 131 | // will print stacktrace 132 | if (app.get('env') === 'development') { 133 | app.use(function (err, req, res, next) { 134 | l(err); 135 | 136 | res.status(err.status || 500); 137 | res.render('error', { 138 | message: err.message, 139 | error: err 140 | }); 141 | }); 142 | } 143 | 144 | // production error handler 145 | // no stacktraces leaked to user 146 | app.use(function (err, req, res, next) { 147 | l(err); 148 | res.status(err.status || 500); 149 | res.render('error', { 150 | message: err.message, 151 | error: {} 152 | }); 153 | }); 154 | 155 | l(`Server listening on port ${port}`) 156 | 157 | server.listen(port); 158 | 159 | module.exports = app; 160 | -------------------------------------------------------------------------------- /constants/constants.js: -------------------------------------------------------------------------------- 1 | const languageNameMap = require('language-name-map/map') 2 | 3 | l = console.log; 4 | 5 | /** STUFF FOR WHISPER **/ 6 | const whisperLanguagesString = 'af,am,ar,as,az,ba,be,bg,bn,bo,br,bs,ca,cs,cy,da,de,el,en,es,et,eu,fa,fi,fo,fr,gl,gu,ha,haw,hi,hr,ht,hu,hy,id,is,it,iw,ja,jw,ka,kk,km,kn,ko,la,lb,ln,lo,lt,lv,mg,mi,mk,ml,mn,mr,ms,mt,my,ne,nl,nn,no,oc,pa,pl,ps,pt,ro,ru,sa,sd,si,sk,sl,sn,so,sq,sr,su,sv,sw,ta,te,tg,th,tk,tl,tr,tt,uk,ur,uz,vi,yi,yo,zh'; 7 | 8 | const whisperLanguagesHumanNames = 'Afrikaans,Albanian,Amharic,Arabic,Armenian,Assamese,Azerbaijani,Bashkir,Basque,Belarusian,Bengali,Bosnian,Breton,Bulgarian,Burmese,Castilian,Catalan,Chinese,Croatian,Czech,Danish,Dutch,English,Estonian,Faroese,Finnish,Flemish,French,Galician,Georgian,German,Greek,Gujarati,Haitian,Haitian Creole,Hausa,Hawaiian,Hebrew,Hindi,Hungarian,Icelandic,Indonesian,Italian,Japanese,Javanese,Kannada,Kazakh,Khmer,Korean,Lao,Latin,Latvian,Letzeburgesch,Lingala,Lithuanian,Luxembourgish,Macedonian,Malagasy,Malay,Malayalam,Maltese,Maori,Marathi,Moldavian,Moldovan,Mongolian,Myanmar,Nepali,Norwegian,Nynorsk,Occitan,Panjabi,Pashto,Persian,Polish,Portuguese,Punjabi,Pushto,Romanian,Russian,Sanskrit,Serbian,Shona,Sindhi,Sinhala,Sinhalese,Slovak,Slovenian,Somali,Spanish,Sundanese,Swahili,Swedish,Tagalog,Tajik,Tamil,Tatar,Telugu,Thai,Tibetan,Turkish,Turkmen,Ukrainian,Urdu,Uzbek,Valencian,Vietnamese,Welsh,Yiddish,Yoruba'; 9 | 10 | const whisperLanguagesHumanReadableArray = whisperLanguagesHumanNames.split(','); 11 | const whisperLanguagesAsSpacedString = whisperLanguagesHumanReadableArray.join(' ') 12 | const languagesArray = whisperLanguagesHumanReadableArray.map(lang => ({value: lang, name: lang})); 13 | languagesArray.unshift({value: 'auto-detect', name: 'Auto-Detect'}); 14 | 15 | function getLanguageCodeForAllLanguages (languageName) { 16 | let foundLanguageCode; 17 | Object.keys(languageNameMap).forEach(languageCode =>{ 18 | if (languageNameMap[languageCode].name === languageName) { 19 | foundLanguageCode = languageCode 20 | } 21 | }); 22 | return foundLanguageCode 23 | } 24 | 25 | const whisperModelsString = 'tiny.en,tiny,base.en,base,small.en,small,medium.en,medium,large'; 26 | const modelsArray = [ 27 | {name: 'Tiny (English Only)', value: 'tiny.en'}, 28 | {name: 'Tiny', value: 'tiny'}, 29 | {name: 'Base (English Only)', value: 'base.en'}, 30 | {name: 'Base', value: 'base'}, 31 | {name: 'Small (English Only)', value: 'small.en'}, 32 | {name: 'Small', value: 'small'}, 33 | {name: 'Medium (English Only)', value: 'medium.en'}, 34 | {name: 'Medium', value: 'medium'}, 35 | {name: 'Large', value: 'large'}, 36 | ]; 37 | 38 | // available models in Libretranslate 39 | const translationLanguages = [ 40 | {'code':'ar','name':'Arabic'}, 41 | {'code':'az','name':'Azerbaijani'}, 42 | {'code':'zh','name':'Chinese'}, 43 | {'code':'cs','name':'Czech'}, 44 | {'code':'da','name':'Danish'}, 45 | {'code':'nl','name':'Dutch'}, 46 | {'code':'en','name':'English'}, 47 | {'code':'fi','name':'Finnish'}, 48 | {'code':'fr','name':'French'}, 49 | {'code':'de','name':'German'}, 50 | {'code':'el','name':'Greek'}, 51 | {'code':'he','name':'Hebrew'}, 52 | {'code':'hi','name':'Hindi'}, 53 | {'code':'hu','name':'Hungarian'}, 54 | {'code':'id','name':'Indonesian'}, 55 | {'code':'ga','name':'Irish'}, 56 | {'code':'it','name':'Italian'}, 57 | {'code':'ja','name':'Japanese'}, 58 | {'code':'ko','name':'Korean'}, 59 | {'code':'fa','name':'Persian'}, 60 | {'code':'pl','name':'Polish'}, 61 | {'code':'pt','name':'Portuguese'}, 62 | {'code':'ru','name':'Russian'}, 63 | {'code':'sk','name':'Slovak'}, 64 | {'code':'es','name':'Spanish'}, 65 | {'code':'sv','name':'Swedish'}, 66 | {'code':'tr','name':'Turkish'}, 67 | {'code':'uk','name':'Ukranian'} 68 | ]; 69 | 70 | const languagesToTranslateTo = [ 71 | // {"code":"ar","name":"Arabic"}, // haven't got these two to work 72 | // {"code":"zh","name":"Chinese"}, // webvtt format is too broken after translate 73 | {'code':'en','name':'English'}, 74 | {'code':'fr','name':'French'}, 75 | {'code':'de','name':'German'}, 76 | {'code':'es','name':'Spanish'}, 77 | {'code':'ru','name':'Russian'}, 78 | {'code':'ja','name':'Japanese'}, 79 | ]; 80 | 81 | // if the human readable name matches thing (or the 'en' version, transcribe 82 | const languagesToTranscribe = [ 83 | 'Arabic', 84 | 'English', 85 | 'French', 86 | 'German', 87 | 'Spanish', 88 | 'Russian', 89 | 'Chinese', 90 | 'Japanese', 91 | 'Serbian' 92 | ] 93 | 94 | // function shouldTranslateFrom(languageName){ 95 | // return translationLanguages.find(function(filteredLanguage){ 96 | // return languageName === filteredLanguage.name; 97 | // }) 98 | // } 99 | 100 | function shouldTranslateFrom (languageName) { 101 | return languagesToTranslateTo.includes(languageName); 102 | } 103 | 104 | let newLanguagesMap = []; 105 | Object.keys(languageNameMap).forEach(languageCode =>{ 106 | newLanguagesMap.push({ 107 | languageCode, 108 | name: languageNameMap[languageCode].name 109 | }) 110 | }); 111 | 112 | let allLanguages = []; 113 | Object.keys(languageNameMap).forEach(languageCode =>{ 114 | allLanguages.push({ 115 | code: languageCode, 116 | name: languageNameMap[languageCode].name 117 | }) 118 | }); 119 | 120 | // l('all languages length'); 121 | // l(allLanguages.length); 122 | 123 | // l('newLanguagesMap', newLanguagesMap); 124 | 125 | // const languagesToTranscribeFrom = 126 | 127 | module.exports = { 128 | whisperLanguagesHumanNames, 129 | languagesArray, 130 | languagesToTranscribe, 131 | whisperLanguagesAsSpacedString, 132 | shouldTranslateFrom, 133 | translationLanguages, 134 | getLanguageCodeForAllLanguages, 135 | newLanguagesMap, 136 | allLanguages, 137 | modelsArray, 138 | languagesToTranslateTo, 139 | whisperLanguagesHumanReadableArray 140 | } 141 | -------------------------------------------------------------------------------- /constants/ytdlpUsers.txt: -------------------------------------------------------------------------------- 1 | Aahedalhamamy -------------------------------------------------------------------------------- /docs/install-libretranslate: -------------------------------------------------------------------------------- 1 | # python install-models.py 2 | # python main.py 3 | 4 | # from browser 5 | const res = await fetch("http://76.50.42.128:49627/translate", { 6 | method: "POST", 7 | body: JSON.stringify({ 8 | q: "Hey here is some translated text!", 9 | source: "auto", 10 | target: "es" 11 | }), 12 | headers: { "Content-Type": "application/json" } 13 | }); 14 | 15 | console.log(await res.json()); 16 | -------------------------------------------------------------------------------- /docs/install.sh: -------------------------------------------------------------------------------- 1 | wget -qO - http://developer.download.nvidia.com/compute/cuda/repos/ubuntu1804/x86_64/3bf863cc.pub | sudo apt-key add - 2 | sudo apt-get update 3 | sudo apt-get upgrade -y 4 | sudo add-apt-repository ppa:deadsnakes/ppa -y 5 | sudo apt install nodejs npm nginx ffmpeg software-properties-common python3 python3.9 python3-pip python3.9-distutils python3.9-dev pkg-config libicu-dev lsof nano -y 6 | sudo update-alternatives --install /usr/bin/python3 python3 /usr/bin/python3.9 1000 7 | pip3 install setuptools-rust 8 | pip3 install --upgrade setuptools 9 | curl https://sh.rustup.rs -sSf | sh -s -- -y 10 | # setting alias this way doesn't work 11 | alias pip=pip3 12 | alias python=python3.9 13 | python -m pip install --upgrade pip 14 | pip3 install --upgrade setuptools 15 | pip install git+https://github.com/openai/whisper.git 16 | 17 | whisper 18 | curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.2/install.sh | bash 19 | 20 | # this is broken I believe 21 | export NVM_DIR="$HOME/.nvm" 22 | [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm 23 | [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" # This loads nvm bash_completion 24 | 25 | nvm install 16 26 | nvm use 16 27 | npm install -g http-server pm2 28 | 29 | 30 | sudo curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp -o /usr/local/bin/yt-dlp 31 | sudo chmod a+rx /usr/local/bin/yt-dlp # Make executable 32 | git clone https://github.com/mayeaux/generate-subtitles 33 | 34 | export LIBRETRANSLATE='http://127.0.0.1:5000' 35 | export CONCURRENT_AMOUNT='2' 36 | export NODE_ENV='production' 37 | 38 | pm2 start npm -- start 39 | 40 | # for libretranslate 41 | #sudo apt-get install python3.9-dev -y 42 | #pip3 install --upgrade distlib 43 | #apt-get install pkg-config libicu-dev 44 | #pip3 install libretranslate 45 | -------------------------------------------------------------------------------- /docs/libretranslate-nginx: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | server_name _; 4 | 5 | location / { 6 | # 30 minute timeout (1800 seconds) 7 | proxy_connect_timeout 1800; 8 | proxy_send_timeout 1800; 9 | proxy_read_timeout 1800; 10 | send_timeout 1800; 11 | 12 | 13 | include proxy_params; 14 | proxy_pass http://localhost:5000; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/nginx-whisper-and-libretranslate.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | listen [::]:80 default_server; 4 | 5 | server_name _; 6 | error_page 405 =200 $uri; 7 | 8 | # max size of 3GB file 9 | client_max_body_size 3000M; 10 | 11 | # set root to serve files out of 12 | root /root/generate-subtitles; 13 | 14 | # serve files using nginx instead of node static 15 | location /transcriptions/ { 16 | gzip off; 17 | 18 | sendfile on; 19 | sendfile_max_chunk 1m; 20 | 21 | tcp_nopush on; 22 | 23 | limit_rate 7m; 24 | limit_rate_after 30m; 25 | 26 | charset utf-8; 27 | types { 28 | text/plain vtt; 29 | text/plain srt; 30 | text/plain txt; 31 | } 32 | 33 | try_files $uri @redirect; 34 | } 35 | 36 | # assumes libretranslate running on port 5000 37 | location /translate { 38 | proxy_pass http://localhost:5000; 39 | proxy_http_version 1.1; 40 | proxy_set_header Upgrade $http_upgrade; 41 | proxy_set_header Connection 'upgrade'; 42 | proxy_set_header Host $host; 43 | proxy_cache_bypass $http_upgrade; 44 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 45 | } 46 | 47 | location @redirect { 48 | proxy_pass http://localhost:3000; 49 | proxy_http_version 1.1; 50 | proxy_set_header Upgrade $http_upgrade; 51 | proxy_set_header Connection 'upgrade'; 52 | proxy_set_header Host $host; 53 | proxy_cache_bypass $http_upgrade; 54 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 55 | } 56 | 57 | # node app running on port 3000 58 | location / { 59 | proxy_pass http://localhost:3000; 60 | proxy_http_version 1.1; 61 | proxy_set_header Upgrade $http_upgrade; 62 | proxy_set_header Connection 'upgrade'; 63 | proxy_set_header Host $host; 64 | proxy_cache_bypass $http_upgrade; 65 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- 1 | ``` 2 | const ltEndpoint = 'https://freesubtitles.ai/translate'; 3 | 4 | const res = await fetch(ltEndpoint, { 5 | method: "POST", 6 | body: JSON.stringify({ 7 | q: "Hey here is some translated text!", 8 | source: "auto", 9 | target: "es" 10 | }), 11 | headers: { "Content-Type": "application/json" } 12 | }); 13 | 14 | console.log(await res.json()); 15 | ``` 16 | -------------------------------------------------------------------------------- /docs/thing.conf: -------------------------------------------------------------------------------- 1 | # if the files are separate, this is in sites-available 2 | 3 | # this file is for your server block, in combo with the http block 4 | 5 | server { 6 | listen 80; 7 | listen [::]:80; 8 | 9 | server_name videoapp.video; 10 | 11 | listen 443 ssl; 12 | 13 | ssl_certificate /home/videoapp/certs/videoapp_video_chain.crt; 14 | ssl_certificate_key /home/videoapp/certs/server.key; 15 | 16 | ## 17 | # Nginx Bad Bot Blocker Includes 18 | # REPO: https://github.com/mitchellkrogza/nginx-ultimate-bad-bot-blocker 19 | ## 20 | #include /etc/nginx/bots.d/ddos.conf; 21 | #include /etc/nginx/bots.d/blockbots.conf; 22 | 23 | error_page 405 =200 $uri; 24 | 25 | # IMPORTANT: set location for the uploads 26 | root /home/videoapp; 27 | 28 | location /files/ { 29 | gzip off; 30 | 31 | sendfile on; 32 | sendfile_max_chunk 1m; 33 | 34 | tcp_nopush on; 35 | 36 | 37 | try_files $uri @redirect; 38 | } 39 | 40 | location @redirect { 41 | proxy_pass http://localhost:3000; 42 | proxy_http_version 1.1; 43 | proxy_set_header Upgrade $http_upgrade; 44 | proxy_set_header Connection 'upgrade'; 45 | proxy_set_header Host $host; 46 | proxy_cache_bypass $http_upgrade; 47 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 48 | } 49 | 50 | location / { 51 | proxy_pass http://localhost:3000; 52 | proxy_http_version 1.1; 53 | proxy_set_header Upgrade $http_upgrade; 54 | proxy_set_header Connection 'upgrade'; 55 | proxy_set_header Host $host; 56 | proxy_cache_bypass $http_upgrade; 57 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 58 | client_max_body_size 8500M; 59 | } 60 | } 61 | 62 | server { 63 | listen 80 default_server; 64 | listen [::]:80 default_server; 65 | 66 | server_name freesubtitles.ai; 67 | 68 | error_page 405 =200 $uri; 69 | 70 | location @redirect { 71 | proxy_pass http://serverAPI:19801; #port 80 72 | proxy_http_version 1.1; 73 | proxy_set_header Upgrade $http_upgrade; 74 | proxy_set_header Connection 'upgrade'; 75 | proxy_set_header Host $host; 76 | proxy_cache_bypass $http_upgrade; 77 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 78 | } 79 | 80 | location / { 81 | proxy_pass http://serverAPI:19801; #port 80 82 | proxy_http_version 1.1; 83 | proxy_set_header Upgrade $http_upgrade; 84 | proxy_set_header Connection 'upgrade'; 85 | proxy_set_header Host $host; 86 | proxy_cache_bypass $http_upgrade; 87 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /downloading/download.js: -------------------------------------------------------------------------------- 1 | const YTDlpWrap = require('yt-dlp-wrap').default; 2 | const which = require('which') 3 | // const transcribe = require('./transcribe'); 4 | 5 | l = console.log; 6 | 7 | // async usage 8 | // rejects if not found 9 | const ytDlpName = process.platform === 'win32' ? 'YoutubeDL' : 'yt-dlp'; 10 | const ytDlpBinaryPath = which.sync(ytDlpName); 11 | 12 | const ytDlpWrap = new YTDlpWrap(ytDlpBinaryPath); 13 | 14 | const testUrl = 'https://www.youtube.com/watch?v=P7ny6-lKoe4'; 15 | 16 | function download (videoUrl, filename) { 17 | let ytDlpEventEmitter = ytDlpWrap 18 | .exec([ 19 | videoUrl, 20 | '-f', 21 | 'bestaudio / b', 22 | '-o', 23 | filename 24 | ]) 25 | .on('progress', (progress) => 26 | console.log( 27 | progress.percent, 28 | progress.totalSize, 29 | progress.currentSpeed, 30 | progress.eta 31 | ) 32 | ) 33 | .on('ytDlpEvent', (eventType, eventData) => 34 | console.log(eventType, eventData) 35 | ) 36 | .on('error', (error) => console.error(error)) 37 | .on('close', () => { 38 | l('done!'); 39 | transcribe(filename) 40 | }); 41 | 42 | // console.log(ytDlpEventEmitter.ytDlpProcess.pid); 43 | } 44 | 45 | async function download (videoUrl, filename) { 46 | let stdout = await ytDlpWrap.execPromise([ 47 | videoUrl, 48 | '-f', 49 | 'bestaudio / b', 50 | '-o', 51 | filename 52 | ]); 53 | 54 | l(stdout); 55 | 56 | return true 57 | 58 | // console.log(ytDlpEventEmitter.ytDlpProcess.pid); 59 | } 60 | 61 | async function getTitle (videoUrl) { 62 | let metadata = await ytDlpWrap.getVideoInfo(videoUrl, '--format', 'bestaudio / b'); 63 | 64 | // l(metadata); 65 | // l(metadata.title); 66 | // l(metadata._filename); 67 | 68 | l(metadata); 69 | 70 | return metadata.title; 71 | } 72 | 73 | // getTitle(testUrl); 74 | // 75 | // l(transcribe); 76 | 77 | async function main (videoUrl) { 78 | const filename = await getTitle(videoUrl); 79 | // l(filename) 80 | await download(videoUrl, filename); 81 | // await transcribe(filename); 82 | } 83 | 84 | // main(); 85 | 86 | const example = 'https://www.youtube.com/watch?v=jXVcLVQ4FTg&ab_channel=HighlightHeaven'; 87 | 88 | // main(example) 89 | 90 | module.exports = main; 91 | 92 | // getTitle(); 93 | // 94 | // download(); 95 | -------------------------------------------------------------------------------- /downloading/yt-dlp-download.js: -------------------------------------------------------------------------------- 1 | const which = require('which'); 2 | const spawn = require('child_process').spawn; 3 | const fs = require('fs-extra'); 4 | const projectConstants = require('../constants/constants'); 5 | const {formatStdErr} = require('../helpers/formatStdErr'); 6 | 7 | // yt-dlp --no-mtime -f '\''bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best'\'' 8 | 9 | const l = console.log; 10 | const ytDlpName = process.platform === 'win32' ? 'YoutubeDL' : 'yt-dlp'; 11 | const ytDlpPath = which.sync(ytDlpName); 12 | 13 | // get data from youtube-dlp stdout string 14 | function extractDataFromString (string) { 15 | const percentDownloaded = parseInt(string.match(/(\d+\.?\d*)%/)[1]); 16 | const totalFileSize = string.match(/of\s+(.*?)\s+at/)[1]; 17 | const downloadSpeed = string.match(/at\s+(.*?)\s+ETA/)[1]; 18 | 19 | const fileSizeValue = totalFileSize.match(/\d+\.\d+/)[0]; 20 | const fileSizeUnit = totalFileSize.split(fileSizeValue)[1]; 21 | 22 | return { 23 | percentDownloaded, 24 | totalFileSize, 25 | downloadSpeed, 26 | fileSizeUnit, 27 | fileSizeValue, 28 | } 29 | } 30 | 31 | // delete from transcription array (used to get rid of the yt-dlp process) 32 | function deleteFromGlobalTranscriptionsBasedOnWebsocketNumber (websocketNumber) { 33 | // check for websocket number and type 34 | function matchDownloadProcessByWebsocketNumber (transcriptionProcess) { 35 | return transcriptionProcess.websocketNumber === websocketNumber && transcriptionProcess.type === 'download'; 36 | } 37 | 38 | // TODO should rename this as processes not transcriptions: 39 | 40 | // delete the download process from the processes array 41 | const transcriptionIndex = global.transcriptions.findIndex(matchDownloadProcessByWebsocketNumber); 42 | if (transcriptionIndex > -1) { // only splice array when item is found 43 | global.transcriptions.splice(transcriptionIndex, 1); // 2nd parameter means remove one item only 44 | } 45 | } 46 | 47 | 48 | async function downloadFile ({ 49 | videoUrl, 50 | filepath, 51 | randomNumber, 52 | websocketConnection, 53 | filename, 54 | websocketNumber 55 | }) { 56 | return new Promise(async (resolve, reject) => { 57 | try { 58 | 59 | let latestDownloadInfo = ''; 60 | 61 | const startedAtTime = new Date(); 62 | 63 | const interval = setInterval(() => { 64 | l(latestDownloadInfo); 65 | 66 | // only run if ETA is in the string 67 | if (!latestDownloadInfo.includes('ETA')) return 68 | 69 | const { percentDownloaded, totalFileSize, downloadSpeed, fileSizeUnit, fileSizeValue } = extractDataFromString(latestDownloadInfo); 70 | 71 | websocketConnection.send(JSON.stringify({ 72 | message: 'downloadInfo', 73 | fileName: filename, 74 | percentDownloaded, 75 | totalFileSize, 76 | downloadSpeed, 77 | startedAtTime, 78 | fileSizeUnit, 79 | fileSizeValue 80 | }), function () {}); 81 | 82 | }, 1000); 83 | 84 | const ytdlProcess = spawn('yt-dlp', [ 85 | videoUrl, 86 | '--no-mtime', 87 | '--no-playlist', 88 | '-f', 89 | 'bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/best[ext=mp4]/best', 90 | '-o', 91 | `./uploads/${randomNumber}.%(ext)s` 92 | ]); 93 | 94 | // add process to global array (for deleting if canceled) 95 | const process = { 96 | websocketNumber, 97 | spawnedProcess: ytdlProcess, 98 | type: 'download' 99 | } 100 | global.transcriptions.push(process) 101 | 102 | ytdlProcess.stdout.on('data', (data) => { 103 | l(`STDOUT: ${data}`); 104 | latestDownloadInfo = data.toString(); 105 | }) 106 | 107 | ytdlProcess.stderr.on('data', (data) => { 108 | l(`STDERR: ${data}`); 109 | }); 110 | 111 | ytdlProcess.on('close', (code) => { 112 | l(`child process exited with code ${code}`); 113 | clearInterval(interval) 114 | if (code === 0) { 115 | resolve(); 116 | } else { 117 | reject(); 118 | } 119 | // TODO: this code is bugged 120 | deleteFromGlobalTranscriptionsBasedOnWebsocketNumber(websocketNumber); 121 | websocketConnection.send(JSON.stringify({ 122 | message: 'downloadingFinished', 123 | }), function () {}); 124 | }); 125 | 126 | } catch (err) { 127 | l('error from download') 128 | l(err); 129 | 130 | reject(err); 131 | 132 | throw new Error(err) 133 | } 134 | 135 | }); 136 | } 137 | 138 | async function downloadFileApi ({ 139 | videoUrl, 140 | filepath, 141 | randomNumber, 142 | filename, 143 | numberToUse 144 | }) { 145 | return new Promise(async (resolve, reject) => { 146 | try { 147 | 148 | let latestDownloadInfo = ''; 149 | let currentPercentDownload = 0; 150 | 151 | const startedAtTime = new Date(); 152 | 153 | const interval = setInterval(() => { 154 | l(latestDownloadInfo); 155 | 156 | // only run if ETA is in the string 157 | if (!latestDownloadInfo.includes('ETA')) return 158 | 159 | const { percentDownloaded, totalFileSize, downloadSpeed, fileSizeUnit, fileSizeValue } = extractDataFromString(latestDownloadInfo); 160 | currentPercentDownload = percentDownloaded; 161 | 162 | }, 1000); 163 | 164 | const ytdlProcess = spawn('yt-dlp', [ 165 | videoUrl, 166 | '--no-mtime', 167 | '--no-playlist', 168 | '-f', 169 | 'bestvideo[ext=mp4][height<=720]+bestaudio[ext=m4a]/best[ext=mp4]/best', 170 | '-o', 171 | `./uploads/${numberToUse}` 172 | ]); 173 | 174 | ytdlProcess.stdout.on('data', (data) => { 175 | l(`STDOUT: ${data}`); 176 | latestDownloadInfo = data.toString(); 177 | }) 178 | 179 | ytdlProcess.stderr.on('data', (data) => { 180 | l(`STDERR: ${data}`); 181 | }); 182 | 183 | ytdlProcess.on('close', (code) => { 184 | l(`child process exited with code ${code}`); 185 | clearInterval(interval) 186 | if (code === 0) { 187 | resolve(); 188 | } else { 189 | reject(); 190 | } 191 | }); 192 | 193 | } catch (err) { 194 | l('error from download') 195 | l(err); 196 | 197 | reject(err); 198 | 199 | throw new Error(err) 200 | } 201 | 202 | }); 203 | } 204 | 205 | // get file title name given youtube url 206 | async function getFilename (videoUrl) { 207 | return new Promise(async (resolve, reject) => { 208 | try { 209 | 210 | const ytdlProcess = spawn('yt-dlp', [ 211 | '--get-filename', 212 | '-f', 213 | 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', 214 | videoUrl 215 | ]); 216 | 217 | ytdlProcess.stdout.on('data', (data) => { 218 | // l(`STDOUTT: ${data}`); 219 | resolve(data.toString().replace(/\r?\n|\r/g, '')); 220 | }) 221 | 222 | ytdlProcess.stderr.on('data', (data) => { 223 | // l(`STDERR: ${data}`); 224 | }); 225 | 226 | ytdlProcess.on('close', (code) => { 227 | l(`child process exited with code ${code}`); 228 | if (code === 0) { 229 | resolve(); 230 | } else { 231 | reject(); 232 | } 233 | }); 234 | 235 | } catch (err) { 236 | l('error from download') 237 | l(err); 238 | 239 | reject(err); 240 | 241 | throw new Error(err) 242 | } 243 | 244 | }); 245 | 246 | } 247 | 248 | const testUrl = 'https://www.youtube.com/watch?v=wnhvanMdx4s'; 249 | 250 | function generateRandomNumber () { 251 | return Math.floor(Math.random() * 10000000000).toString(); 252 | } 253 | 254 | const randomNumber = generateRandomNumber(); 255 | 256 | async function main () { 257 | const title = await getFilename(testUrl); 258 | l(title); 259 | await downloadFile({ 260 | videoUrl: testUrl, 261 | randomNumber, 262 | filepath: `./${title}` 263 | }) 264 | } 265 | 266 | // main() 267 | 268 | module.exports = { 269 | downloadFile, 270 | downloadFileApi, 271 | getFilename 272 | }; 273 | -------------------------------------------------------------------------------- /examples/dnevnik.srt: -------------------------------------------------------------------------------- 1 | WEBVTT 2 | 3 | 00:00.000 --> 00:24.860 4 | Dubar den. Â sam Mirela Vasin, a ovo su Vesti dana. 5 | 6 | 00:24.860 --> 00:34.860 7 | Na jarinju uhapšen Srbin, bivši pripadnik Kosovske policije. Srbi počale da se okupljaju i postavljaju barikade. 8 | 9 | 00:34.860 --> 00:44.860 10 | Premijerka Anna Brnabić apelovala na Međunarodnu zajednicu da ne okreće glavu od ljudskih prava Srba na kosmetu. 11 | 12 | 00:44.860 --> 00:52.860 13 | Ukrajina trađi dodatno oružje, Moskva upozorava na posledice. 14 | 15 | 00:52.860 --> 01:04.860 16 | Danas se igraju dva četvrtfinalna meća na svetskom futbolskom prvenstvu – Marokko-Portugalija i Engleska-Francuska. 17 | -------------------------------------------------------------------------------- /helpers/formatStdErr.js: -------------------------------------------------------------------------------- 1 | const l = console.log; 2 | 3 | const ten = ' 10%|█ | 5332/52135 [00:10<01:25, 545.77frames/s]'; 4 | 5 | function formatStdErr (stdErrData) { 6 | // if a progress output 7 | if (stdErrData.includes('frames/s')) { 8 | // looks like: '█ ' 9 | const progressBar = stdErrData.split('|')[1].split('|')[0] 10 | 11 | // looks like: '10%' 12 | let percentDone = stdErrData.split('|')[0].trim(); 13 | 14 | // looks like: 10 15 | let percentDoneAsNumber = Number(stdErrData.split('%')[0].trim()); 16 | 17 | // looks like: '00:10<01:25, 545.77frames/s]' 18 | let timeLeftPortion = stdErrData.split('[')[1].split('[')[0] 19 | 20 | // looks like: '00:10<01:25' 21 | const firstPortion = timeLeftPortion.split(',')[0] 22 | 23 | // looks like: '00:10' 24 | const timeElapsed = firstPortion.split('<')[0] 25 | 26 | // looks like: '01:25' 27 | const timeRemainingString = timeLeftPortion.split('<')[1].split(',')[0] 28 | 29 | // looks like: '545.77' 30 | const speed = timeLeftPortion.split('<')[1].split(',')[1].split('frames')[0].trim() 31 | 32 | // looks like: '545.77' 33 | const splitTimeRemaining = timeRemainingString.split(':') 34 | 35 | // looks like: '01' 36 | const secondsRemaining = Number(splitTimeRemaining.pop()); 37 | 38 | // looks like: '25' 39 | const minutesRemaining = Number(splitTimeRemaining.pop()); 40 | 41 | // looks like: 'NaN' 42 | const hoursRemaining = Number(splitTimeRemaining.pop()); 43 | 44 | // format for lib 45 | return { 46 | progressBar, 47 | percentDone, 48 | timeElapsed, 49 | speed, 50 | percentDoneAsNumber, 51 | timeRemaining: { 52 | string: timeRemainingString, 53 | hoursRemaining, 54 | minutesRemaining, 55 | secondsRemaining 56 | }, 57 | } 58 | } else { 59 | return false 60 | } 61 | } 62 | 63 | module.exports = { 64 | formatStdErr 65 | } -------------------------------------------------------------------------------- /helpers/getLanguageNames.js: -------------------------------------------------------------------------------- 1 | function shouldTranslateTo (languageName) { 2 | return translateLanguages.filter(function (filteredLanguage) { 3 | return languageName === filteredLanguage.name; 4 | }) 5 | } 6 | 7 | function shouldTranslateFrom (languageName) { 8 | return translateLanguages.filter(function (filteredLanguage) { 9 | return languageName === filteredLanguage.name; 10 | }) 11 | } 12 | -------------------------------------------------------------------------------- /helpers/helpers.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Translates seconds into human readable format of seconds, minutes, hours, days, and years 3 | * 4 | * @param {number} seconds The number of seconds to be processed 5 | * @return {string} The phrase describing the amount of time 6 | */ 7 | // poor naming -osb910 8 | function forHumans ( seconds ) { 9 | let levels = [ 10 | [Math.floor(seconds / 31536000), 'years'], 11 | [Math.floor((seconds % 31536000) / 86400), 'days'], 12 | [Math.floor(((seconds % 31536000) % 86400) / 3600), 'hours'], 13 | [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minutes'], 14 | [(((seconds % 31536000) % 86400) % 3600) % 60, 'seconds'], 15 | ]; 16 | let returntext = ''; 17 | 18 | for (let i = 0, max = levels.length; i < max; i++) { 19 | if ( levels[i][0] === 0 ) continue; 20 | returntext += ' ' + levels[i][0] + ' ' + (levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length-1): levels[i][1]); 21 | }; 22 | return returntext.trim(); 23 | } 24 | 25 | function forHumansNoSeconds ( seconds ) { 26 | let levels = [ 27 | [Math.floor(seconds / 31536000), 'years'], 28 | [Math.floor((seconds % 31536000) / 86400), 'days'], 29 | [Math.floor(((seconds % 31536000) % 86400) / 3600), 'hours'], 30 | [Math.floor((((seconds % 31536000) % 86400) % 3600) / 60), 'minutes'], 31 | ]; 32 | let returntext = ''; 33 | 34 | for (let i = 0, max = levels.length; i < max; i++) { 35 | if ( levels[i][0] === 0 ) continue; 36 | returntext += ' ' + levels[i][0] + ' ' + (levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length-1): levels[i][1]); 37 | }; 38 | return returntext.trim(); 39 | } 40 | 41 | function forHumansHoursAndMinutes ( seconds ) { 42 | let levels = [ 43 | [Math.floor(seconds / 3600), 'hours'], 44 | [Math.floor((seconds % 3600) / 60), 'minutes'], 45 | ]; 46 | let returntext = ''; 47 | 48 | for (let i = 0, max = levels.length; i < max; i++) { 49 | if ( levels[i][0] === 0 ) continue; 50 | returntext += ' ' + levels[i][0] + ' ' + (levels[i][0] === 1 ? levels[i][1].substr(0, levels[i][1].length-1): levels[i][1]); 51 | }; 52 | return returntext.trim(); 53 | } 54 | 55 | 56 | 57 | const decrementBySecond = timeRemainingValues => { 58 | let {secondsRemaining, minutesRemaining, hoursRemaining} = timeRemainingValues; 59 | 60 | if (secondsRemaining == 0) { 61 | if (minutesRemaining > 0) { 62 | secondsRemaining = 59; 63 | minutesRemaining--; 64 | } 65 | } else { 66 | secondsRemaining--; 67 | } 68 | 69 | if (minutesRemaining == 0) { 70 | if (hoursRemaining > 0) { 71 | minutesRemaining = 59; 72 | hoursRemaining--; 73 | } 74 | } 75 | 76 | minutesRemaining = `${minutesRemaining}`.padStart(2, '0'); 77 | secondsRemaining = `${secondsRemaining}`.padStart(2, '0'); 78 | 79 | const wholeTime = `${hoursRemaining ? hoursRemaining + ':' : ''}${minutesRemaining}:${secondsRemaining}`; 80 | 81 | return { 82 | secondsRemaining, 83 | minutesRemaining, 84 | hoursRemaining, 85 | string: wholeTime 86 | } 87 | } 88 | 89 | module.exports = { 90 | forHumans, 91 | forHumansNoSeconds, 92 | decrementBySecond, 93 | forHumansHoursAndMinutes 94 | } 95 | -------------------------------------------------------------------------------- /lib/convertText.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const convert = require('cyrillic-to-latin'); 3 | const { simplified } = require('zh-convert'); 4 | 5 | async function convertSerbianCyrillicToLatin ({ 6 | transcribedSrtFilePath, 7 | transcribedVttFilePath, 8 | transcribedTxtFilePath, 9 | }) { 10 | let data = await fs.readFile(transcribedSrtFilePath, 'utf-8'); 11 | let latinCharactersText = convert(data); 12 | await fs.writeFile(transcribedSrtFilePath, latinCharactersText, 'utf-8'); 13 | 14 | data = await fs.readFile(transcribedVttFilePath, 'utf-8'); 15 | latinCharactersText = convert(data); 16 | await fs.writeFile(transcribedVttFilePath, latinCharactersText, 'utf-8'); 17 | 18 | data = await fs.readFile(transcribedTxtFilePath, 'utf-8'); 19 | latinCharactersText = convert(data); 20 | await fs.writeFile(transcribedTxtFilePath, latinCharactersText, 'utf-8'); 21 | } 22 | 23 | async function convertChineseTraditionalToSimplified ({ 24 | transcribedSrtFilePath, 25 | transcribedVttFilePath, 26 | transcribedTxtFilePath, 27 | }) { 28 | let data = await fs.readFile(transcribedSrtFilePath, 'utf-8'); 29 | let simplifiedText = simplified(data); 30 | await fs.writeFile(transcribedSrtFilePath, simplifiedText, 'utf-8'); 31 | 32 | data = await fs.readFile(transcribedVttFilePath, 'utf-8'); 33 | simplifiedText = simplified(data); 34 | await fs.writeFile(transcribedVttFilePath, simplifiedText, 'utf-8'); 35 | 36 | data = await fs.readFile(transcribedTxtFilePath, 'utf-8'); 37 | simplifiedText = simplified(data); 38 | await fs.writeFile(transcribedTxtFilePath, simplifiedText, 'utf-8'); 39 | } 40 | 41 | module.exports = { 42 | convertSerbianCyrillicToLatin, 43 | convertChineseTraditionalToSimplified, 44 | } 45 | -------------------------------------------------------------------------------- /lib/files.js: -------------------------------------------------------------------------------- 1 | const filenamify = require('filenamify'); 2 | const fs = require('fs-extra'); 3 | const moment = require('moment/moment'); 4 | 5 | const makeFileNameSafe = function (string) { 6 | return filenamify(string, {replacement: '_' }) 7 | .replace(/[&\/\\#,+()$~%.'":*?<>{}!]/g, '') 8 | .replace(/\s+/g,'_') 9 | .split(':').join(':'); 10 | } 11 | 12 | function decode_utf8 (s) { 13 | return decodeURIComponent(escape(s)); 14 | } 15 | 16 | // it's an array of file names 17 | const getAllDirectories = async (dir) => { 18 | let files = await fs.promises.readdir(dir, { withFileTypes: true }); 19 | 20 | let newFiles = []; 21 | 22 | for (let file of files) { 23 | // l('file'); 24 | // l(file); 25 | // l(file.name); 26 | // l(file.isDirectory()); 27 | if (!file.isDirectory()) continue; 28 | 29 | let processingData; 30 | try { 31 | processingData = JSON.parse(await fs.readFile(`${dir}/${file.name}/processing_data.json`, 'utf8')); 32 | } catch (err) { 33 | // l('err'); 34 | // l(err); 35 | processingData = null; 36 | } 37 | // 38 | // l('processing data'); 39 | // l(processingData); 40 | 41 | if (processingData && processingData.startedAt && processingData.uploadDurationInSeconds) { 42 | newFiles.push({ 43 | name: file.name, 44 | processingData, 45 | formattedDate: moment(processingData.startedAt).format('D MMM YYYY'), 46 | timestamp: processingData.startedAt && new Date(processingData.startedAt).getTime() 47 | }); 48 | } 49 | } 50 | 51 | return newFiles 52 | } 53 | 54 | async function sortByModifiedAtTime (dir) { 55 | // sort by modified date 56 | return files 57 | .map(async fileName => ({ 58 | name: fileName, 59 | time: await fs.stat(`${dir}/${fileName}`).mtime.getTime(), 60 | })) 61 | .sort((a, b) => a.time - b.time) 62 | .map(file => file.name); 63 | } 64 | 65 | async function getMatchingFiles ({ files, language, keepMedia }) { 66 | // TODO: ugly design but can't think of a better approach atm 67 | let keepMediaMatch; 68 | if (keepMedia === false) { 69 | keepMediaMatch = undefined; 70 | } else { 71 | keepMediaMatch = keepMedia; 72 | } 73 | 74 | if (language) { 75 | files = files.filter((file) => { 76 | return file.processingData.language === language; 77 | }); 78 | } 79 | 80 | if (keepMediaMatch !== undefined) { 81 | files = files.filter((file) => { 82 | return file.processingData.keepMedia === keepMediaMatch; 83 | }); 84 | } 85 | 86 | return files; 87 | } 88 | 89 | module.exports = { 90 | makeFileNameSafe, 91 | decode_utf8, 92 | getAllDirectories, 93 | sortByModifiedAtTime, 94 | getMatchingFiles 95 | } 96 | -------------------------------------------------------------------------------- /lib/stats.js: -------------------------------------------------------------------------------- 1 | // one minute in milliseconds 2 | const fs = require('fs-extra'); 3 | const {forHumansHoursAndMinutes} = require('../helpers/helpers'); 4 | const oneMinute = 1000 * 60; 5 | 6 | const interval = oneMinute; 7 | 8 | global.siteStats = {} 9 | 10 | const existingTranscriptions = 0; 11 | const existingSecondsTranscribed = 0; 12 | 13 | async function getTranscriptionData () { 14 | let totalSeconds = 0; 15 | 16 | const processDirectory = process.cwd(); 17 | const transcriptionsDirectory = `${processDirectory}/transcriptions`; 18 | await fs.mkdirp(transcriptionsDirectory); 19 | 20 | const transcriptionsDirectoryContents = await fs.readdir(transcriptionsDirectory); 21 | 22 | // loop through all transcription directories 23 | for (const transcriptionDirectory of transcriptionsDirectoryContents) { 24 | // check if directory is directory 25 | const directoryPath = `${transcriptionsDirectory}/${transcriptionDirectory}`; 26 | 27 | // this is guaranteed to exist 28 | const directoryStats = await fs.stat(directoryPath); 29 | 30 | const isDirectory = directoryStats.isDirectory(); 31 | 32 | // only loop through if it's a directory 33 | if (isDirectory) { 34 | // check if directory has a processing_data.json file 35 | const processingDataPath = `${directoryPath}/processing_data.json`; 36 | 37 | // read processing_data.json file 38 | // dont error if processingData doesn't exist 39 | const processingDataExists = await fs.pathExists(processingDataPath); 40 | 41 | if (!processingDataExists) { 42 | continue 43 | } 44 | 45 | let processingData, fileExistsButJsonError; 46 | try { 47 | processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 48 | } catch (err) { 49 | 50 | // syntax error 51 | fileExistsButJsonError = err.toString().includes('SyntaxError'); 52 | 53 | // delete the media if json error 54 | if (fileExistsButJsonError) { 55 | continue 56 | } 57 | } 58 | 59 | if (!processingData) { 60 | continue 61 | } 62 | 63 | const uploadDurationInSeconds = processingData.uploadDurationInSeconds; 64 | 65 | if (uploadDurationInSeconds) { 66 | totalSeconds += uploadDurationInSeconds; 67 | } 68 | 69 | 70 | } 71 | } 72 | 73 | totalSeconds = totalSeconds + existingSecondsTranscribed; 74 | 75 | global.siteStats = { 76 | totalSeconds: totalSeconds, 77 | amountOfTranscriptions: transcriptionsDirectoryContents.length + existingTranscriptions, 78 | humanReadableTime: forHumansHoursAndMinutes(totalSeconds), 79 | } 80 | 81 | l('siteStats'); 82 | l(global.siteStats); 83 | } 84 | 85 | // run immediately on boot 86 | getTranscriptionData(); 87 | 88 | // Schedule the directory reading operation at regular intervals 89 | setInterval(async () => { 90 | getTranscriptionData(); 91 | }, interval); 92 | -------------------------------------------------------------------------------- /lib/transcribing.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const {autoDetectLanguage} = require('../transcribe/transcribing'); 3 | const {formatStdErr} = require('../helpers/formatStdErr'); 4 | const { getLanguageCodeForAllLanguages } = require('../constants/constants'); 5 | const filenamify = require('filenamify'); 6 | const path = require('path'); 7 | 8 | async function writeToProcessingDataFile (processingDataPath, dataObject) { 9 | // save data to the file 10 | const processingDataExists = await fs.exists(processingDataPath) 11 | 12 | l('processingDataExists') 13 | l(processingDataExists); 14 | if (processingDataExists) { 15 | const fileData = await fs.readFile(processingDataPath, 'utf8') 16 | l('fileData'); 17 | l(fileData); 18 | 19 | const existingProcessingData = JSON.parse(fileData); 20 | 21 | let merged = Object.assign({}, existingProcessingData, dataObject); 22 | 23 | l('merged'); 24 | l(merged); 25 | 26 | await fs.writeFile(processingDataPath, JSON.stringify(merged), 'utf8'); 27 | } else { 28 | await fs.writeFile(processingDataPath, JSON.stringify(dataObject), 'utf8'); 29 | } 30 | } 31 | 32 | function detectLanguageFromString (dataAsString) { 33 | if (!dataAsString) return false 34 | if (dataAsString.includes('Detected language:')) { 35 | // parse out the language from the console output 36 | return dataAsString.split(':')[1].substring(1).trimEnd(); 37 | } 38 | return false; 39 | } 40 | 41 | function handleStdOut (data) { 42 | l(`STDOUT: ${data}`) 43 | 44 | // save auto-detected language 45 | const parsedLanguage = autoDetectLanguage(data.toString()); 46 | return parsedLanguage 47 | } 48 | 49 | // print the latest progress and save it to the processing data file 50 | function handleStdErr ({ 51 | model, language, originalFileName, processingDataPath 52 | }) { 53 | return function (data) { 54 | (async function () { 55 | l(`STDERR: ${data}`) 56 | 57 | // get value from the whisper output string 58 | const formattedProgress = formatStdErr(data.toString()); 59 | l('formattedProgress'); 60 | l(formattedProgress); 61 | 62 | const { percentDoneAsNumber, percentDone, speed, timeRemaining } = formattedProgress; 63 | 64 | // TODO: add speed here and timeremaining 65 | 66 | // save info to processing_data.json 67 | await writeToProcessingDataFile(processingDataPath, { 68 | progress: percentDoneAsNumber, 69 | status: 'processing', 70 | model, 71 | language, 72 | languageCode: getLanguageCodeForAllLanguages(language), 73 | originalFileName 74 | }) 75 | 76 | })() 77 | } 78 | } 79 | 80 | const outputFileExtensions = ['.srt', '.vtt', '.txt'] 81 | 82 | // rename files to proper names for api (remove file extension) 83 | async function moveFiles (randomNumber, fileExtension) { 84 | const holderFolder = `${process.cwd()}/transcriptions/${randomNumber}`; 85 | for (const extension of outputFileExtensions) { 86 | const oldLocation = `${holderFolder}/${randomNumber}.${fileExtension}${extension}` 87 | await fs.move(oldLocation, `${holderFolder}/${randomNumber}${extension}`) 88 | } 89 | } 90 | 91 | // when whisper process finishes 92 | function handleProcessClose ({ processingDataPath, originalUpload, numberToUse }) { 93 | return function (code) { 94 | (async function () { 95 | l(`PROCESS FINISHED WITH CODE: ${code}`) 96 | 97 | const processFinishedSuccessfullyBasedOnStatusCode = code === 0; 98 | 99 | // if process failed 100 | if (!processFinishedSuccessfullyBasedOnStatusCode) { 101 | // if process errored out 102 | await writeToProcessingDataFile(processingDataPath, { 103 | status: 'error', 104 | error: 'whisper process failed' 105 | }) 106 | 107 | // throw error if failed 108 | throw new Error('Whisper process did not exit successfully'); 109 | } else { 110 | // // TODO: pass file extension to this function 111 | // const fileExtension = originalUpload.split('.').pop(); 112 | // 113 | // // rename whisper created files 114 | // await moveFiles(numberToUse, fileExtension) 115 | 116 | // save mark upload as completed transcribing 117 | await writeToProcessingDataFile(processingDataPath, { 118 | status: 'completed', 119 | }) 120 | 121 | } 122 | })() 123 | } 124 | } 125 | 126 | // example file from multer 127 | // { 128 | // fieldname: 'file', 129 | // originalname: 'dutch_language.mp3', 130 | // encoding: '7bit', 131 | // mimetype: 'audio/mpeg', 132 | // destination: './uploads/', 133 | // filename: '572fa0ecb660b1d0eb489b879c2e2310', 134 | // path: 'uploads/572fa0ecb660b1d0eb489b879c2e2310', 135 | // size: 22904865 136 | // } 137 | 138 | // make sure the file name is safe for the file system 139 | const makeFileNameSafe = function (string) { 140 | return filenamify(string, {replacement: '_' }) // replace all non-URL-safe characters with an underscore 141 | .split(':').join(':') // replace chinese colon with english colon 142 | .replace(/[&\/\\#,+()$~%.'":*?<>{}!]/g, '') // remove special characters 143 | .replace(/\s+/g,'_') // replace spaces with underscores 144 | } 145 | 146 | // 147 | function createFileNames (originalFileName) { 148 | // name of file without extension 149 | const originalFileNameWithoutExtension = path.parse(originalFileName).name; 150 | 151 | return { 152 | originalFileNameWithExtension: originalFileName, // original file name 153 | originalFileExtension: path.parse(originalFileName).ext, // file extension 154 | originalFileNameWithoutExtension, // file name with extension removed 155 | directorySafeFileNameWithoutExtension: makeFileNameSafe(originalFileNameWithoutExtension), // safe file name for directory name 156 | } 157 | } 158 | 159 | module.exports = { 160 | // handle processing data file 161 | writeToProcessingDataFile, 162 | 163 | detectLanguageFromString, 164 | 165 | // handle output from the whisper process 166 | handleStdOut, 167 | handleStdErr, 168 | handleProcessClose, 169 | 170 | // file name helpers 171 | makeFileNameSafe, 172 | createFileNames 173 | } -------------------------------------------------------------------------------- /lib/websockets.js: -------------------------------------------------------------------------------- 1 | const WebSocket = require('ws'); 2 | const WebSocketServer = WebSocket.WebSocketServer; 3 | const { getQueueInformationByWebsocketNumber } = require('../queue/newQueue'); 4 | const { updateQueueItemStatus } = require('../queue/queue'); 5 | 6 | function deleteFromGlobalTranscriptionsBasedOnWebsocketNumber (websocketNumber) { 7 | // find transcription based on websocketNumber 8 | const closerTranscription = global['transcriptions'].find(function (transcription) { 9 | return transcription.websocketNumber === websocketNumber; 10 | }) 11 | 12 | // 13 | const transcriptionIndex = global.transcriptions.indexOf(closerTranscription); 14 | 15 | // only splice array when item is found 16 | if (transcriptionIndex > -1) { 17 | // 2nd parameter means remove one item only 18 | global.transcriptions.splice(transcriptionIndex, 1); 19 | } 20 | } 21 | 22 | 23 | /** when websocket disconnects **/ 24 | function deleteWebsocketAndEndProcesses ({ websocketNumber, websocketConnection, websocket, index }) { 25 | l(`Disconnected user found: ${websocketNumber}`); 26 | 27 | function matchByWebsocketNumber (item) { 28 | return item.websocketNumber === websocketNumber; 29 | } 30 | 31 | // disconnect websocket and delete from global holder 32 | websocketConnection.terminate(); 33 | global.webSocketData.splice(index, 1); 34 | l(`${websocketNumber} Deleted from global.webSocketData`); 35 | 36 | // find transcription based on websocketNumber 37 | const foundProcess = global.transcriptions.find(matchByWebsocketNumber) 38 | 39 | const existingProcess = foundProcess && foundProcess.spawnedProcess; 40 | 41 | // kill the process 42 | if (existingProcess) { 43 | 44 | // TODO: save processing info and conditionally kill 45 | 46 | // kill spawned process 47 | foundProcess.spawnedProcess.kill('SIGINT'); 48 | l(`Found and killed process: ${websocketNumber}`); 49 | 50 | // delete from transcription array 51 | const transcriptionIndex = global.transcriptions.findIndex(matchByWebsocketNumber); 52 | 53 | // only splice array when item is found 54 | if (index > -1) { 55 | global.transcriptions.splice(transcriptionIndex, 1); // 2nd parameter means remove one item only 56 | } 57 | } 58 | 59 | // delete from queue 60 | const queueIndex = global.newQueue.findIndex(matchByWebsocketNumber); 61 | 62 | // only splice array when item is found 63 | if (queueIndex > -1) { 64 | 65 | // 2nd parameter means remove one item only 66 | global.newQueue.splice(queueIndex, 1); 67 | 68 | l(`${websocketNumber} Deleted from global.newQueue`); 69 | } 70 | 71 | // only updates if not marked as completed 72 | updateQueueItemStatus(websocketNumber, 'abandoned'); 73 | 74 | // inform every websocket that is in the queue of their updated queue position 75 | sendOutQueuePositionUpdate(); 76 | } 77 | 78 | /** 79 | * find queue information for each websocket and send it (if they're in the queue) 80 | */ 81 | function sendOutQueuePositionUpdate () { 82 | // loop through websockets and tell them one less is processing 83 | for (let [, websocket] of global.webSocketData.entries() ) { 84 | // the actual websocket 85 | // l(websocket.websocketNumber) 86 | const websocketConnection = websocket.websocket; 87 | const websocketNumber = websocket.websocketNumber; 88 | 89 | if (websocketConnection.readyState === WebSocket.OPEN) { 90 | 91 | const { queuePosition } = getQueueInformationByWebsocketNumber(websocketNumber); 92 | 93 | // l('queuePosition'); 94 | // l(queuePosition); 95 | 96 | if (queuePosition) { 97 | websocketConnection.send(JSON.stringify({ 98 | message: 'queue', 99 | placeInQueue: queuePosition 100 | }), function () {}); 101 | } 102 | } 103 | } 104 | } 105 | 106 | /** 107 | * check for websockets that haven't marked themselves as alive 108 | **/ 109 | function checkForDeath () { 110 | // tell console how many are connected 111 | const totalAmountOfWebsockets = global.webSocketData.length; 112 | l(`Disconnect Check for ${totalAmountOfWebsockets}`); 113 | 114 | // loop through array of objects of websockets 115 | for (let [index, websocket] of global['webSocketData'].entries() ) { 116 | // the actual websocket 117 | // l(websocket.websocketNumber) 118 | const websocketNumber = websocket.websocketNumber 119 | const websocketConnection = websocket.websocket; 120 | 121 | /** DEAD WEBSOCKET FUNCTIONALITY **/ 122 | // destroy killed websockets and cancel their transcriptions 123 | if (websocketConnection.isAlive === false) { 124 | deleteWebsocketAndEndProcesses({ websocketNumber, websocketConnection, websocket, index }); 125 | } 126 | 127 | /** TEST FOR ALIVENESS */ 128 | // mark them as dead, but then check immediately after for redemption chance 129 | websocketConnection.isAlive = false; 130 | // trigger their pong event 131 | websocketConnection.ping(); 132 | } 133 | } 134 | 135 | // run on first connection 136 | function setupWebsocket (websocketConnection, request) { 137 | // random number generated from the frontend (last part of the hit url) 138 | const websocketNumber = request.url.split('/')[1]; 139 | 140 | // TODO: handle case that it's info websocket push 141 | 142 | 143 | 144 | // add to global array of websockets 145 | global.webSocketData.push({ 146 | websocketNumber, 147 | websocket: websocketConnection, 148 | status: 'alive', 149 | }) 150 | 151 | // chart that it exists for first time (add to global.ws) 152 | websocketConnection.isAlive = true; 153 | 154 | // send websocket number back to parent function 155 | return websocketNumber; 156 | } 157 | 158 | // called from app.js 159 | function createWebSocketServer (server) { 160 | 161 | // create websocket server 162 | const wss = new WebSocketServer({ server }); 163 | 164 | // instantiate global array of websocket connections 165 | global.webSocketData = [] 166 | 167 | // when a user hits the websocket server 168 | wss.on('connection', function (websocketConnection, request, client) { 169 | 170 | // add to websocketQueue, and mark as alive 171 | const websocketNumber = setupWebsocket(websocketConnection, request); 172 | 173 | l(`websocket connected: ${websocketNumber}`); 174 | 175 | // server sets all websockets as dead, but then checks for life, set for true if alive 176 | websocketConnection.on('pong', () => websocketConnection.isAlive = true ) 177 | 178 | // log when user connects / disconnect 179 | websocketConnection.on('close', () => { 180 | l('websocket connection') 181 | // l(websocketConnection) 182 | l(`websocket closed: ${websocketNumber}`) 183 | // websocketConnection.isAlive = false 184 | // checkForDeath(); 185 | }); 186 | }); 187 | 188 | // check every 5 seconds for dead sockets (still takes 10s) 189 | setInterval(checkForDeath, 1000 * 5); 190 | } 191 | 192 | module.exports = { 193 | createWebSocketServer, 194 | sendOutQueuePositionUpdate, 195 | }; 196 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "init", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app.js", 7 | "dev": "nodemon app.js", 8 | "lint": "eslint .", 9 | "lint-fix": "eslint . --fix" 10 | }, 11 | "nodemonConfig": { 12 | "ignore": [ 13 | "transcriptions/*", 14 | "uploads/*" 15 | ] 16 | }, 17 | "dependencies": { 18 | "await-spawn": "^4.0.2", 19 | "axios": "^1.1.3", 20 | "body-parser": "^1.20.1", 21 | "cookie-parser": "~1.3.5", 22 | "cyrillic-to-latin": "^2.0.0", 23 | "debug": "^4.3.4", 24 | "dotenv": "^16.0.3", 25 | "express": "^4.18.2", 26 | "express-session": "^1.17.3", 27 | "ffprobe": "^1.1.2", 28 | "filenamify": "^4.3.0", 29 | "form-data": "^4.0.0", 30 | "fs-extra": "^10.1.0", 31 | "google-translate-api-browser": "^3.0.0", 32 | "language-name-map": "^0.3.0", 33 | "lodash": "^4.17.21", 34 | "moment": "^2.29.4", 35 | "morgan": "^1.10.0", 36 | "multer": "^1.4.5-lts.1", 37 | "node-fetch": "^2.6.7", 38 | "promise-queue": "^2.2.5", 39 | "pug": "^3.0.2", 40 | "serve-favicon": "^2.5.0", 41 | "srt2vtt": "^1.3.1", 42 | "which": "^3.0.0", 43 | "ws": "^8.10.0", 44 | "yt-dlp-wrap": "^2.3.11", 45 | "zh-convert": "^1.0.1" 46 | }, 47 | "devDependencies": { 48 | "eslint": "^8.30.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /public/images/Octocat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/Octocat.png -------------------------------------------------------------------------------- /public/images/discordLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/discordLogo.png -------------------------------------------------------------------------------- /public/images/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/favicon.ico -------------------------------------------------------------------------------- /public/images/gh-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/gh-logo.png -------------------------------------------------------------------------------- /public/images/githublogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/githublogo.png -------------------------------------------------------------------------------- /public/images/inverted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/inverted.png -------------------------------------------------------------------------------- /public/images/telegramLogo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/telegramLogo.webp -------------------------------------------------------------------------------- /public/images/thing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/thing.png -------------------------------------------------------------------------------- /public/images/transcriptionIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/transcriptionIcon.png -------------------------------------------------------------------------------- /public/images/twitterLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mayeaux/generate-subtitles/6218f4b83318a93e42040fce21ff96f54752243b/public/images/twitterLogo.png -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | -------------------------------------------------------------------------------- /public/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 50px; 3 | font: 14px "Lucida Grande", Helvetica, Arial, sans-serif; 4 | } 5 | 6 | a { 7 | color: #00B7FF; 8 | } 9 | -------------------------------------------------------------------------------- /queue/newQueue.js: -------------------------------------------------------------------------------- 1 | const transcribeWrapped = require('../transcribe/transcribe-wrapped'); 2 | // const { sendOutQueuePositionUpdate } = require('../lib/websockets'); 3 | const WebSocket = require('ws'); 4 | 5 | const l = console.log; 6 | 7 | const maxConcurrentJobs = Number(process.env.CONCURRENT_AMOUNT); 8 | 9 | // create set of numbers from x, such as 1,2,3 10 | function createNumberSet (x) { 11 | return Array.from({length: x}, (_, i) => i + 1); 12 | } 13 | 14 | l('maxConcurrentJobs'); 15 | l(maxConcurrentJobs); 16 | const numberSet = createNumberSet(maxConcurrentJobs); 17 | 18 | global.jobProcesses = {}; 19 | 20 | for (const number of numberSet) { 21 | global.jobProcesses[number] = undefined; 22 | } 23 | 24 | l(global.jobProcesses); 25 | 26 | // find process number of job to clear it when done 27 | function findProcessNumber (websocketNumber) { 28 | for (let processNumber in global.jobProcesses) { 29 | 30 | const hasOwnProperty = global.jobProcesses.hasOwnProperty(processNumber) 31 | 32 | if (hasOwnProperty) { 33 | 34 | const matchesByWebsocket = global.jobProcesses[processNumber]?.websocketNumber === websocketNumber; 35 | if (matchesByWebsocket) { 36 | return processNumber 37 | } 38 | } 39 | } 40 | 41 | return false 42 | 43 | // TODO: throw an error here? 44 | } 45 | function sendOutQueuePositionUpdate () { 46 | // loop through websockets and tell them one less is processing 47 | for (let [, websocket] of global['webSocketData'].entries() ) { 48 | // the actual websocket 49 | // l(websocket.websocketNumber) 50 | const websocketConnection = websocket.websocket; 51 | const websocketNumber = websocket.websocketNumber; 52 | 53 | if (websocketConnection.readyState === WebSocket.OPEN) { 54 | 55 | const { queuePosition } = getQueueInformationByWebsocketNumber(websocketNumber); 56 | 57 | // l('queuePosition'); 58 | // l(queuePosition); 59 | 60 | if (queuePosition) { 61 | websocketConnection.send(JSON.stringify({ 62 | message: 'queue', 63 | placeInQueue: queuePosition 64 | }), function () {}); 65 | } 66 | 67 | // // TODO: send queue messages here 68 | // websocketConnection.send(JSON.stringify('finishedProcessing')); 69 | } 70 | } 71 | } 72 | 73 | 74 | // run transcribe job and remove from queue and run next queue item if available 75 | async function runJob (jobObject) { 76 | const { websocketNumber } = jobObject; 77 | 78 | // simulate job running 79 | try { 80 | await transcribeWrapped(jobObject); 81 | 82 | l('job done'); 83 | 84 | } catch (err) { 85 | l('error from runjob'); 86 | l(err); 87 | } 88 | 89 | const processNumber = findProcessNumber(websocketNumber); 90 | l('processNumber'); 91 | l(processNumber); 92 | 93 | // run the next item from the queue 94 | if (global.newQueue.length) { 95 | const nextQueueItem = global.newQueue.shift(); 96 | 97 | nextQueueItem.processNumber = Number(processNumber); 98 | 99 | global.jobProcesses[processNumber] = nextQueueItem; 100 | 101 | // TODO: add got out of queue time here 102 | runJob(nextQueueItem); 103 | } else { 104 | global.jobProcesses[processNumber] = undefined; 105 | } 106 | } 107 | 108 | global.newQueue = []; 109 | 110 | // add job to process if available otherwise add to queue 111 | function addToJobProcessOrQueue (jobObject) { 112 | const { websocketNumber, skipToFront } = jobObject; 113 | 114 | l('skipToFront'); 115 | l(skipToFront); 116 | 117 | // put job on process if there is an available process 118 | for (let processNumber in global.jobProcesses) { 119 | const propValue = global.jobProcesses[processNumber]; 120 | 121 | if (propValue === undefined) { 122 | jobObject.processNumber = Number(processNumber); 123 | 124 | global.jobProcesses[processNumber] = jobObject; 125 | runJob(jobObject); 126 | return 127 | } 128 | } 129 | 130 | // TODO: add got in queue time here 131 | 132 | // push to newQueue if all processes are busy 133 | if (skipToFront) { 134 | // last skip to front item 135 | const lastItem = global.newQueue.filter(queueItem => queueItem.skipToFront === true).slice(-1)[0]; 136 | 137 | // insert after latest skipToFront 138 | if (lastItem) { 139 | const lastItemIndex = global.newQueue.indexOf(lastItem); 140 | 141 | // insert after last item with skipToFront 142 | global.newQueue.splice(lastItemIndex + 1, 0, jobObject); 143 | } else { 144 | // insert at beginning 145 | global.newQueue.unshift(jobObject); 146 | } 147 | 148 | } else { 149 | // insert at end 150 | global.newQueue.push(jobObject); 151 | } 152 | 153 | sendOutQueuePositionUpdate(); 154 | } 155 | 156 | // get amount of running jobs (used to calculate queue position) 157 | function amountOfRunningJobs () { 158 | let amount = 0; 159 | for (let processNumber in global.jobProcesses) { 160 | const propValue = global.jobProcesses[processNumber]; 161 | 162 | if (propValue !== undefined) { 163 | amount++; 164 | } 165 | } 166 | 167 | return amount; 168 | } 169 | 170 | // get position in queue based on websocketNumber 171 | function getQueueInformationByWebsocketNumber (websocketNumber) { 172 | for (const [index, queueItem] of global.newQueue.entries()) { 173 | if (queueItem.websocketNumber === websocketNumber) { 174 | return { 175 | queuePosition: index + 1, // 1 176 | queueLength: global.newQueue.length, // 4 177 | aheadOfYou: index, 178 | behindYou: global.newQueue.length - index - 1 179 | } 180 | } 181 | } 182 | return false 183 | } 184 | 185 | module.exports = { 186 | addToJobProcessOrQueue, 187 | amountOfRunningJobs, 188 | getQueueInformationByWebsocketNumber 189 | } 190 | 191 | // function main(){ 192 | // addToJobProcessOrQueue({websocketNumber: 0, skipToFront: false}); 193 | // addToJobProcessOrQueue({websocketNumber: 1, skipToFront: true}); 194 | // addToJobProcessOrQueue({websocketNumber: 2, skipToFront: false}); 195 | // 196 | // addToJobProcessOrQueue({websocketNumber: 3, skipToFront: false}); 197 | // addToJobProcessOrQueue({websocketNumber: 4, skipToFront: true}); 198 | // 199 | // l(global.newQueue); 200 | // } 201 | 202 | // main(); 203 | 204 | // async function delay(delayInSeconds) { 205 | // await new Promise(resolve => setTimeout(resolve, delayInSeconds * 1000)); 206 | // } 207 | // 208 | // function generateRandomNumber(){ 209 | // return Math.floor(Math.random() * 4 + 3); 210 | // } 211 | 212 | // async function main(){ 213 | // addToJobProcessOrQueue({ websocketNumber: '1234', seconds: 15 }); 214 | // await delay(generateRandomNumber()); 215 | // // l('delay done') 216 | // addToJobProcessOrQueue({ websocketNumber: '2345', seconds: 8 }); 217 | // await delay(generateRandomNumber()); 218 | // // l('delay done') 219 | // addToJobProcessOrQueue({ websocketNumber: '5678', seconds: 5 }); 220 | // } 221 | 222 | // main(); 223 | 224 | // setInterval(() => { 225 | // l('job object'); 226 | // l(jobProcesses); 227 | // l('queue'); 228 | // l(newQueue); 229 | // }, 1000); 230 | 231 | // async function delayJob(seconds){ 232 | // l('delaying 5000'); 233 | // await delay(seconds * 1000); 234 | // l('delay done'); 235 | // } 236 | // 237 | // const newDelayJob = delayJob(3); 238 | // l(newDelayJob) 239 | // 240 | // async function main1(){ 241 | // l('starting'); 242 | // await delay(5000); 243 | // l('delay 1 done'); 244 | // await newDelayJob; 245 | // } 246 | // 247 | // main1() 248 | 249 | // function addJobToProcessesObject(processNumber, jobObject){ 250 | // 251 | // } 252 | 253 | // async function addToJobProcessOrQueue({ websocketNumber, seconds }){ 254 | // let startedJob = false; 255 | // for (let prop in jobProcesses) { 256 | // const propValue = jobProcesses[prop]; 257 | // // l(prop, jobObject[prop]); 258 | // 259 | // if(propValue === undefined){ 260 | // jobProcesses[prop] = websocketNumber; 261 | // runJob({ seconds, websocketNumber }); 262 | // startedJob = true; 263 | // return 264 | // } 265 | // l(prop, jobProcesses[prop]); 266 | // } 267 | // 268 | // if(!startedJob){ 269 | // queue.push({ 270 | // websocketNumber, 271 | // seconds, 272 | // }) 273 | // l('added to queue'); 274 | // } 275 | // } 276 | 277 | 278 | // async function doNextQueueItem(){ 279 | // if(queue.length > 0){ 280 | // const nextItem = queue.shift(); 281 | // await nextItem(); 282 | // doNextQueueItem(); 283 | // } 284 | // } 285 | -------------------------------------------------------------------------------- /queue/queue.js: -------------------------------------------------------------------------------- 1 | global.queueJobs = []; 2 | 3 | global.queueItems = []; 4 | 5 | function addItemToQueue (queueData) { 6 | global.queueItems.push(queueData) 7 | } 8 | 9 | function addItemToQueueJobs (queueData) { 10 | global.queueJobs.push(queueData) 11 | } 12 | 13 | function updateQueueItemStatus (websocketNumber, status) { 14 | const item = global.queueItems.find((item) => item.websocketNumber === websocketNumber); 15 | if (item && item.status !== 'completed') { 16 | item.status = status; 17 | } 18 | } 19 | 20 | function getNumberOfPendingOrProcessingJobs (ip) { 21 | const numberOfPendingOrProcessingJobs = global.queueItems.filter((item) => item.ip === ip && (item.status === 'pending' || item.status === 'processing')).length; 22 | return numberOfPendingOrProcessingJobs; 23 | } 24 | 25 | module.exports = { 26 | addItemToQueue, 27 | addItemToQueueJobs, 28 | updateQueueItemStatus, 29 | getNumberOfPendingOrProcessingJobs 30 | } 31 | -------------------------------------------------------------------------------- /routes/admin.js: -------------------------------------------------------------------------------- 1 | // see files 2 | const _ = require('lodash'); 3 | const express = require('express'); 4 | const router = express.Router(); 5 | const { getAllDirectories, getMatchingFiles } = require('../lib/files'); 6 | 7 | router.get('/files', async function (req, res, next) { 8 | try { 9 | const { password, language } = req.query; 10 | 11 | const keepMedia = req.query.keepMedia === 'true'; 12 | 13 | if (password !== process.env.FILES_PASSWORD) { 14 | res.redirect('/404') 15 | } else { 16 | const dir = './transcriptions'; 17 | 18 | // 19 | let files = await getAllDirectories('./transcriptions'); 20 | 21 | // log files length 22 | l('files length'); 23 | l(files.length); 24 | // l(files); 25 | 26 | // TODO: what other things to match against? 27 | files = await getMatchingFiles({ dir, files, language, keepMedia }); 28 | 29 | files = _.orderBy(files, (file) => new Date(file.processingData.finishedAT), 'desc'); 30 | 31 | // // log files length 32 | // l('files length'); 33 | // l(files.length); 34 | // 35 | // files = await sortByModifiedAtTime('./transcriptions'); 36 | 37 | 38 | // most recently effected files first (non-destructive, functional) 39 | // files = [].concat(files).reverse(); 40 | 41 | // log files length 42 | // l('files length'); 43 | // l(files.length); 44 | // 45 | // l('returning'); 46 | // l(files); 47 | 48 | return res.render('files', { 49 | // list of file names 50 | files, 51 | title: 'Files', 52 | }) 53 | } 54 | 55 | } catch (err) { 56 | l('err'); 57 | l(err); 58 | } 59 | }); 60 | 61 | // see files 62 | router.get('/learnserbian', async function (req, res, next) { 63 | try { 64 | 65 | const dir = './transcriptions'; 66 | // 67 | let files = await getAllDirectories('./transcriptions'); 68 | 69 | const language = 'Serbian'; 70 | const keepMedia = true; 71 | 72 | // TODO: what other things to match against? 73 | files = await getMatchingFiles({ dir, files, language, keepMedia }); 74 | 75 | l('files length'); 76 | l(files.length); 77 | l(files); 78 | 79 | files = files.filter(function (file) { 80 | return file.processingData.translatedLanguages.length; 81 | }); 82 | 83 | // TODO: finishedAT is misspelled 84 | files = _.orderBy(files, (file) => new Date(file.processingData.finishedAT), 'desc'); 85 | 86 | return res.render('files', { 87 | // list of file names 88 | files, 89 | title: 'Files', 90 | }) 91 | 92 | } catch (err) { 93 | l('err'); 94 | l(err); 95 | } 96 | }); 97 | 98 | router.get('/admin', async function (req, res, next) { 99 | try { 100 | const { password } = req.query; 101 | 102 | if (process.env.NODE_ENV !== 'development' && password !== process.env.FILES_PASSWORD) { 103 | res.redirect('/404') 104 | } else { 105 | 106 | // l('jobProcesses') 107 | // l(jobProcesses) 108 | 109 | const cleanedUpJobProcessObject = {}; 110 | 111 | for (const jobProcessNumber in jobProcesses) { 112 | let value = jobProcesses[jobProcessNumber]; 113 | if (!value) { 114 | cleanedUpJobProcessObject[jobProcessNumber] = {}; 115 | continue 116 | } 117 | 118 | let newItem = Object.assign({}, value); 119 | delete newItem.directorySafeFileNameWithoutExtension; 120 | delete newItem.directorySafeFileNameWithExtension; 121 | delete newItem.fileSafeNameWithDateTimestamp 122 | delete newItem.fileSafeNameWithDateTimestampAndExtension 123 | cleanedUpJobProcessObject[jobProcessNumber] = newItem; 124 | } 125 | 126 | // l('cleanedUpJobProcessObject') 127 | // l(cleanedUpJobProcessObject) 128 | 129 | const cleanedUpNewQueue = []; 130 | 131 | // l('global newqueue') 132 | // l(global.newQueue); 133 | 134 | // cleanup new queue items 135 | for (const queueItem of global.newQueue) { 136 | 137 | if (!queueItem) continue 138 | 139 | let newItem = Object.assign({}, queueItem); 140 | 141 | delete newItem.directorySafeFileNameWithoutExtension; 142 | delete newItem.directorySafeFileNameWithExtension; 143 | delete newItem.fileSafeNameWithDateTimestamp 144 | delete newItem.fileSafeNameWithDateTimestampAndExtension 145 | cleanedUpNewQueue.push(newItem); 146 | } 147 | 148 | // l('cleanedUpNewQueue') 149 | // l(cleanedUpNewQueue) 150 | 151 | return res.render('admin', { 152 | title: 'Admin', 153 | processes: cleanedUpJobProcessObject, 154 | newQueue: cleanedUpNewQueue || [], 155 | transcriptions: global.transcriptions, 156 | webSocketData: global.webSocketData, 157 | }) 158 | } 159 | 160 | } catch (err) { 161 | l('err'); 162 | l(err); 163 | } 164 | }); 165 | 166 | module.exports = router; 167 | -------------------------------------------------------------------------------- /routes/api.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const express = require('express'); 3 | const axios = require('axios'); 4 | const fs = require('fs-extra'); 5 | const FormData = require('form-data'); 6 | const multer = require('multer'); 7 | const router = express.Router(); 8 | const transcribe = require('../transcribe/transcribe-api-wrapped') 9 | const constants = require('../constants/constants'); 10 | const filenamify = require('filenamify'); 11 | const createTranslatedFiles = require('../translate/translate-files-api'); 12 | const { downloadFileApi, getFilename} = require('../downloading/yt-dlp-download'); 13 | const { languagesToTranslateTo, newLanguagesMap, translationLanguages } = constants; 14 | const { modelsArray, whisperLanguagesHumanReadableArray } = constants; 15 | const { writeToProcessingDataFile, createFileNames, makeFileNameSafe } = require('../lib/transcribing'); 16 | 17 | const l = console.log; 18 | 19 | // generate random 10 digit number 20 | function generateRandomNumber () { 21 | return Math.floor(Math.random() * 10000000000).toString(); 22 | } 23 | 24 | const storage = multer.diskStorage({ // notice you are calling the multer.diskStorage() method here, not multer() 25 | destination: function (req, file, cb) { 26 | cb(null, './uploads/') 27 | }, 28 | }); 29 | 30 | let upload = multer({ storage }); 31 | 32 | router.post('/api', upload.single('file'), async function (req, res, next) { 33 | try { 34 | // fix body data 35 | const postBodyData = Object.assign({},req.body) 36 | 37 | // get file names 38 | const file = req.file; 39 | let originalFileName, uploadFileName, uploadFilePath; 40 | if (file) { 41 | originalFileName = file.originalname; 42 | uploadFileName = file.filename; 43 | uploadFilePath = file.path; 44 | } 45 | 46 | l('originalFileName'); 47 | l(originalFileName); 48 | 49 | l('uploadFileName'); 50 | l(uploadFileName) 51 | 52 | l(req.file); 53 | 54 | // get language and model 55 | const { model, language, downloadLink, apiToken, websocketNumber } = postBodyData; 56 | 57 | let numberToUse; 58 | if (downloadLink) { 59 | numberToUse = generateRandomNumber(); 60 | } else { 61 | numberToUse = websocketNumber; 62 | } 63 | 64 | l('postBodyData'); 65 | l(postBodyData); 66 | 67 | // get model values as array 68 | const validModelValues = modelsArray.map((model) => model.value); 69 | 70 | const authTokenString = await fs.readFile(`${process.cwd()}/constants/apiTokens.txt`, 'utf8'); 71 | const authTokenStringsAsArray = authTokenString.split(','); 72 | const authedByToken = authTokenStringsAsArray.includes(apiToken); 73 | 74 | if (process.env.NODE_ENV === 'production' && !authedByToken) { 75 | return res.status(401).json({ error: 'Unauthorized' }); 76 | } 77 | 78 | // nothing to transcribe 79 | if (!downloadLink && !file) { 80 | // eslint-disable-next-line quotes 81 | return res.status(400).json({error: `Please pass either a 'file' or 'downloadLink'`}); 82 | } 83 | 84 | // bad model name 85 | if (!validModelValues.includes(model)) { 86 | return res.status(400).send({error: `Your model of '${model}' is not valid. Please choose one of the following: ${validModelValues.join(', ')}`}); 87 | } 88 | 89 | // bad language name 90 | if (!whisperLanguagesHumanReadableArray.includes(language)) { 91 | return res.status(400).send({error: `Your language of '${language}' is not valid. Please choose one of the following: ${whisperLanguagesHumanReadableArray.join(', ')}`}); 92 | } 93 | 94 | // TODO: implement this 95 | let originalFileNameWithExtension, originalFileExtension, originalFileNameWithoutExtension, directorySafeFileNameWithoutExtension; 96 | if (file) { 97 | ({ 98 | originalFileNameWithExtension, 99 | originalFileExtension, 100 | originalFileNameWithoutExtension, 101 | directorySafeFileNameWithoutExtension 102 | } = createFileNames(originalFileName)); 103 | } 104 | 105 | let filename; 106 | if (downloadLink) { 107 | // hit yt-dlp and get file title name 108 | filename = await getFilename(downloadLink); 109 | } else { 110 | filename = originalFileNameWithExtension 111 | } 112 | 113 | const directoryName = makeFileNameSafe(filename) 114 | 115 | l('directoryName'); 116 | l(directoryName); 117 | 118 | l('filename'); 119 | l(filename); 120 | 121 | // build this properly 122 | const host = process.env.NODE_ENV === 'production' ? 'https://freesubtitles.ai' : 'http://localhost:3001'; 123 | 124 | // create directory for transcriptions 125 | await fs.mkdirp(`${process.cwd()}/transcriptions/${numberToUse}`); 126 | 127 | const newPath = `${process.cwd()}/transcriptions/${numberToUse}/${numberToUse}`; 128 | 129 | // setup path for processing data 130 | const processingDataPath = `${process.cwd()}/transcriptions/${numberToUse}/processing_data.json`; 131 | 132 | // save initial data 133 | await writeToProcessingDataFile(processingDataPath, { 134 | model, 135 | language, 136 | downloadLink, 137 | filename, 138 | apiToken 139 | }) 140 | 141 | let matchingFile; 142 | if (downloadLink) { 143 | 144 | res.send({ 145 | message: 'starting-download', 146 | // where the data will be sent from 147 | transcribeDataEndpoint: `${host}/api/${numberToUse}`, 148 | fileTitle: filename, 149 | }); 150 | 151 | await writeToProcessingDataFile(processingDataPath, { 152 | status: 'downloading', 153 | }) 154 | 155 | // download file with name as the random number 156 | await downloadFileApi({ 157 | videoUrl: downloadLink, 158 | numberToUse, 159 | }); 160 | 161 | // check uploads directory 162 | const files = await fs.promises.readdir(`${process.cwd()}/uploads`); 163 | 164 | // get matching file (I don't think we always know the extension) 165 | matchingFile = files.filter((file) => file.startsWith(numberToUse))[0]; 166 | l(matchingFile); 167 | 168 | uploadFilePath = `${process.cwd()}/uploads/${matchingFile}`; 169 | } else { 170 | res.send({ 171 | message: 'starting-transcription', 172 | // where the data will be sent from 173 | transcribeDataEndpoint: `${host}/api/${numberToUse}`, 174 | fileTitle: filename, 175 | }); 176 | } 177 | 178 | // move transcribed file to the correct location (TODO: do this before transcribing) 179 | await fs.move(uploadFilePath, newPath) 180 | 181 | await writeToProcessingDataFile(processingDataPath, { 182 | status: 'starting-transcription', 183 | }) 184 | 185 | // todo: rename to transcribeAndTranslate 186 | await transcribe({ 187 | language, 188 | model, 189 | originalFileExtension, 190 | uploadFileName: matchingFile || originalFileName, // 191 | uploadFilePath: newPath, 192 | originalFileName, 193 | numberToUse, 194 | }) 195 | 196 | } catch (err) { 197 | l('err') 198 | l(err); 199 | return res.status(500).send({error: `Something went wrong: ${err}`}); 200 | } 201 | }); 202 | 203 | // get info about the transcription via api 204 | router.get('/api/:sdHash', async function (req, res, next) { 205 | try { 206 | 207 | l('Getting info by SDHash'); 208 | 209 | // TODO: should rename this 210 | const sdHash = req.params.sdHash; 211 | 212 | l('sd hash') 213 | l(sdHash); 214 | 215 | // get processing data path 216 | const processingData = JSON.parse(await fs.readFile(`./transcriptions/${sdHash}/processing_data.json`, 'utf8')); 217 | 218 | // get data from processing data 219 | const { 220 | language, 221 | languageCode, 222 | translatedLanguages, 223 | status: transcriptionStatus, 224 | progress 225 | } = processingData; 226 | 227 | // transcription processing or translating 228 | if (transcriptionStatus === 'processing' || transcriptionStatus === 'translating') { 229 | // send current processing data 230 | return res.send({ 231 | status: transcriptionStatus, 232 | sdHash, 233 | progress, 234 | processingData 235 | }) 236 | 237 | /** transcription successfully completed, attach VTT files **/ 238 | } else if (transcriptionStatus === 'completed') { 239 | let subtitles = []; 240 | 241 | // add original vtt 242 | const originalVtt = await fs.readFile(`./transcriptions/${sdHash}/${sdHash}.vtt`, 'utf8'); 243 | subtitles.push({ 244 | language, 245 | languageCode, 246 | webVtt: originalVtt 247 | }) 248 | 249 | // for (const translatedLanguage of translatedLanguages) { 250 | // const originalVtt = await fs.readFile(`./transcriptions/${sdHash}/${sdHash}_${translatedLanguage}.vtt`, 'utf8'); 251 | // subtitles.push({ 252 | // language: translatedLanguage, 253 | // languageCode: getCodeFromLanguageName(translatedLanguage), 254 | // webVtt: originalVtt 255 | // }) 256 | // } 257 | 258 | // send response as json 259 | const responseObject = { 260 | status: 'completed', 261 | sdHash, 262 | processingData, 263 | subtitles 264 | } 265 | // l('responseObject'); 266 | // l(responseObject); 267 | 268 | return res.send(responseObject) 269 | } 270 | 271 | 272 | 273 | return res.send(processingData); 274 | 275 | // res.send('ok'); 276 | } catch (err) { 277 | l('err'); 278 | l(err); 279 | } 280 | }) 281 | 282 | 283 | 284 | 285 | 286 | /** UNFINISHED FUNCTIONALITY **/ 287 | // post file from backend 288 | router.post('/post', async function (req, res, next) { 289 | try { 290 | l(req.body); 291 | l(req.params); 292 | 293 | const endpointToHit = 'http:localhost:3000' 294 | 295 | // Create a new form instance 296 | const form = new FormData(); 297 | 298 | const file = await fs.readFile('./ljubav.srt'); 299 | l('file'); 300 | l(file); 301 | 302 | form.append('subtitles', file, 'subtitles'); 303 | 304 | form.append('filename', 'ljubav.srt'); 305 | 306 | 307 | l('form headers'); 308 | l(form.getHeaders()) 309 | 310 | const response = await axios.post(endpointToHit, form, { 311 | headers: { 312 | ...form.getHeaders(), 313 | }, 314 | data: { 315 | foo: 'bar', // This is the body part 316 | } 317 | }); 318 | 319 | // l('response'); 320 | // l(response); 321 | 322 | // res.send('ok'); 323 | } catch (err) { 324 | l('err'); 325 | l(err); 326 | } 327 | }) 328 | 329 | module.exports = router; 330 | -------------------------------------------------------------------------------- /routes/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const {forHumans, decrementBySecond} = require('../helpers/helpers') 4 | const { modelsArray, languagesArray } = require('../constants/constants'); 5 | const fs = require('fs-extra') 6 | 7 | const l = console.log; 8 | 9 | const uploadPath = process.env.UPLOAD_PATH || 'localhost:3000'; 10 | 11 | const nodeEnv = process.env.NODE_ENV || 'development'; 12 | l({nodeEnv}); 13 | 14 | const uploadLimitInMB = nodeEnv === 'production' ? process.env.UPLOAD_FILE_SIZE_LIMIT_IN_MB : 3000; 15 | l({uploadLimitInMB}); 16 | 17 | // home page 18 | router.get('/', function (req, res, next) { 19 | const isFreeSubtitles = req.hostname === 'freesubtitles.ai'; 20 | 21 | // transcribe frontend page 22 | res.render('index/index', { 23 | title: 'Transcribe File', 24 | uploadPath, 25 | forHumans, 26 | nodeEnv, 27 | siteStats: global.siteStats, 28 | isFreeSubtitles, 29 | uploadLimitInMB, 30 | modelsArray, 31 | languagesArray, 32 | decrementBySecond 33 | }); 34 | }); 35 | 36 | // home page 37 | router.get('/ytdlp', async function (req, res, next) { 38 | 39 | const { password, user, skip } = req.query; 40 | 41 | const usersString = await fs.readFile(`${process.cwd()}/constants/ytdlpUsers.txt`, 'utf8'); 42 | const users = usersString.split(','); 43 | const userAuthed = users.includes(user); 44 | 45 | const passwordAuthed = password === process.env.FILES_PASSWORD 46 | 47 | const authedByPasswordOrUser = userAuthed || passwordAuthed; 48 | 49 | if (nodeEnv === 'production' && !authedByPasswordOrUser) { 50 | return res.redirect('/404') 51 | } 52 | 53 | const domainName = req.hostname; 54 | 55 | const isFreeSubtitles = domainName === 'freesubtitles.ai'; 56 | 57 | // transcribe frontend page 58 | res.render('index/index', { 59 | title: 'Transcribe File', 60 | uploadPath, 61 | forHumans, 62 | nodeEnv, 63 | siteStats: global.siteStats, 64 | isFreeSubtitles, 65 | uploadLimitInMB, 66 | modelsArray, 67 | languagesArray, 68 | decrementBySecond, 69 | ytdlp: true, 70 | user, 71 | skipToFront: skip 72 | }); 73 | }); 74 | 75 | router.get('/queue', function (req, res, next) { 76 | 77 | const { password } = req.query; 78 | 79 | if (nodeEnv === 'production' && password !== process.env.FILES_PASSWORD) { 80 | return res.redirect('/404') 81 | } 82 | 83 | const queueData = global.queueItems; 84 | 85 | const reversedQueueData = queueData.slice().reverse(); 86 | 87 | res.render('queue', { 88 | title: 'Queue', 89 | queueData: reversedQueueData, 90 | }) 91 | }); 92 | 93 | // router.get("/transcriptions/:path/:filename" , async function(req, res, next) { 94 | // console.log(req.params); 95 | // res.sendFile(`${process.cwd()}/transcriptions/${req.params.path}/${req.params.filename}`); 96 | // }); 97 | 98 | module.exports = router; 99 | -------------------------------------------------------------------------------- /routes/player.js: -------------------------------------------------------------------------------- 1 | const { stripOutTextAndTimestamps, reformatVtt } = require ('../translate/helpers') 2 | const { Readable } = require('stream'); 3 | const fs = require('fs-extra'); 4 | const {newLanguagesMap, languagesToTranscribe} = require('../constants/constants'); 5 | const express = require('express'); 6 | let router = express.Router(); 7 | 8 | 9 | /** PLYR PLAYER **/ 10 | router.get('/player/:filename' , async function (req, res, next) { 11 | try { 12 | const { password } = req.query; 13 | 14 | const userAuthed = password === process.env.FILES_PASSWORD 15 | 16 | const fileNameWithoutExtension = req.params.filename 17 | 18 | const processDirectory = process.cwd(); 19 | 20 | const containingFolder = `${processDirectory}/transcriptions/${fileNameWithoutExtension}` 21 | 22 | const processingDataPath = `${containingFolder}/processing_data.json`; 23 | 24 | const processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 25 | 26 | 27 | const filePathWithoutExtension = `/transcriptions/${fileNameWithoutExtension}/${processingData.directoryFileName}`; 28 | 29 | // l('filePathWithoutExtension') 30 | // l(filePathWithoutExtension); 31 | 32 | const translatedLanguages = processingData.translatedLanguages; 33 | 34 | // TODO: check that it doesn't include the original language? or it never will? 35 | const languagesToLoop = newLanguagesMap.filter(function (language) { 36 | return translatedLanguages.includes(language.name) 37 | }); 38 | 39 | delete processingData.strippedText; 40 | delete processingData.timestampsArray; 41 | 42 | // l('processing data'); 43 | // l(processingData); 44 | // 45 | // l('languages to loop'); 46 | // l(languagesToLoop); 47 | 48 | let allLanguages = languagesToLoop.slice(); 49 | 50 | allLanguages.push({ 51 | name: processingData.language, 52 | languageCode: processingData.languageCode 53 | }) 54 | 55 | // l('all languages'); 56 | // l(allLanguages); 57 | 58 | res.render('player/player', { 59 | filePath: filePathWithoutExtension, 60 | languages: languagesToTranscribe, 61 | fileNameWithoutExtension, 62 | filePathWithoutExtension, 63 | processingData, 64 | title: processingData.filename, 65 | languagesToLoop, 66 | allLanguages, 67 | renderedFilename: req.params.filename, 68 | userAuthed, 69 | password 70 | // vttPath, 71 | // fileSource 72 | }) 73 | } catch (err) { 74 | l('err'); 75 | l(err); 76 | res.redirect('/404') 77 | } 78 | }); 79 | 80 | /** player route to add translation **/ 81 | router.get('/player/:filename/add' , async function (req, res, next) { 82 | try { 83 | 84 | const fileNameWithoutExtension = req.params.filename 85 | 86 | const processDirectory = process.cwd(); 87 | 88 | const containingFolder = `${processDirectory}/transcriptions/${fileNameWithoutExtension}` 89 | 90 | const processingDataPath = `${containingFolder}/processing_data.json`; 91 | 92 | const processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 93 | 94 | const originalVtt = await fs.readFile(`${containingFolder}/${processingData.directoryFileName}.vtt`, 'utf8'); 95 | 96 | res.render('addTranslation/addTranslation', { 97 | title: 'Add Translation', 98 | renderedFilename: fileNameWithoutExtension, 99 | originalVtt 100 | // vttPath, 101 | // fileSource 102 | }) 103 | } catch (err) { 104 | l('err'); 105 | l(err); 106 | res.send(err); 107 | } 108 | }); 109 | 110 | /** PLYR PLAYER **/ 111 | router.post('/player/:filename/add' , async function (req, res, next) { 112 | try { 113 | 114 | const { language } = req.body; 115 | 116 | const fileNameWithoutExtension = req.params.filename 117 | 118 | const newVtt = req.body.message; 119 | 120 | const processDirectory = process.cwd(); 121 | 122 | const containingFolder = `${processDirectory}/transcriptions/${fileNameWithoutExtension}` 123 | 124 | const processingDataPath = `${containingFolder}/processing_data.json`; 125 | 126 | const processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 127 | 128 | const originalVttPath = `${containingFolder}/${processingData.directoryFileName}.vtt`; 129 | 130 | const originalVtt = await fs.readFile(`${containingFolder}/${processingData.directoryFileName}.vtt`, 'utf8'); 131 | 132 | const inputStream = new Readable(newVtt); 133 | 134 | inputStream.push(newVtt); 135 | 136 | inputStream.push(null); 137 | 138 | l(inputStream) 139 | 140 | const { strippedText } = await stripOutTextAndTimestamps(inputStream, true); 141 | 142 | l('stripped text'); 143 | l(strippedText); 144 | 145 | const { timestampsArray } = await stripOutTextAndTimestamps(originalVttPath); 146 | 147 | l('timestamps array'); 148 | l(timestampsArray); 149 | 150 | const reformatted = reformatVtt(timestampsArray, strippedText); 151 | 152 | l(reformatted); 153 | l('refomatted'); 154 | 155 | const newVttPath = `${containingFolder}/${processingData.directoryFileName}_${language}.vtt`; 156 | 157 | const originalFileVtt = `${containingFolder}/${processingData.directoryFileName}_${processingData.language}.vtt`; 158 | 159 | await fs.writeFile(newVttPath, reformatted, 'utf-8'); 160 | 161 | processingData.translatedLanguages.push(language); 162 | 163 | processingData.keepMedia = true; 164 | 165 | await fs.writeFile(processingDataPath, JSON.stringify(processingData), 'utf-8'); 166 | 167 | await fs.writeFile(originalFileVtt, originalVtt, 'utf-8'); 168 | 169 | return res.redirect(`/player/${req.params.filename}`) 170 | 171 | } catch (err) { 172 | l('err'); 173 | l(err); 174 | res.send(err); 175 | } 176 | }); 177 | 178 | /** CHANGE KEEP MEDIA **/ 179 | router.post('/player/:filename/keepMedia' , async function (req, res, next) { 180 | try { 181 | const { password } = req.query; 182 | 183 | const keepMedia = req.query.keepMedia; 184 | 185 | const shouldKeepMedia = keepMedia === 'true'; 186 | 187 | l('keep media'); 188 | l(keepMedia); 189 | 190 | l('password'); 191 | l(password); 192 | 193 | const fileNameWithoutExtension = req.params.filename 194 | 195 | const processDirectory = process.cwd(); 196 | 197 | const containingFolder = `${processDirectory}/transcriptions/${fileNameWithoutExtension}` 198 | 199 | const processingDataPath = `${containingFolder}/processing_data.json`; 200 | 201 | const processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 202 | 203 | if (shouldKeepMedia) { 204 | processingData.keepMedia = true; 205 | } else { 206 | processingData.keepMedia = false; 207 | } 208 | 209 | await fs.writeFile(processingDataPath, JSON.stringify(processingData), 'utf-8'); 210 | 211 | return res.redirect(`/player/${req.params.filename}`) 212 | 213 | } catch (err) { 214 | l('err'); 215 | l(err); 216 | res.send(err); 217 | } 218 | }); 219 | 220 | 221 | module.exports = router; 222 | -------------------------------------------------------------------------------- /routes/stats.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const moment = require('moment'); 4 | const { forHumansHoursAndMinutes } = require('../helpers/helpers') 5 | 6 | const { getAllDirectories } = require('../lib/files'); 7 | 8 | // see files 9 | router.get('/stats', async function (req, res, next) { 10 | try { 11 | 12 | const stats = { 13 | lastHour: 0, 14 | last24h: 0, 15 | lastWeek: 0, 16 | lastMonth: 0, 17 | allTime: 0 18 | } 19 | 20 | const transcriptionTime = { 21 | lastHour: 0, 22 | last24h: 0, 23 | lastWeek: 0, 24 | lastMonth: 0, 25 | allTime: 0 26 | } 27 | 28 | // 29 | let files = await getAllDirectories('./transcriptions'); 30 | 31 | const withinLastHour = moment().subtract(1, 'hours').valueOf(); 32 | const within24h = moment().subtract(1, 'days').valueOf(); 33 | const withinWeek = moment().subtract(1, 'weeks').valueOf(); 34 | const withinMonth = moment().subtract(1, 'months').valueOf(); 35 | 36 | let languages = {}; 37 | 38 | for (const file of files) { 39 | if (file.timestamp > withinLastHour) { 40 | stats.lastHour++; 41 | transcriptionTime.lastHour += file.processingData.uploadDurationInSeconds; 42 | } 43 | if (file.timestamp > within24h) { 44 | stats.last24h++; 45 | transcriptionTime.last24h += file.processingData.uploadDurationInSeconds; 46 | } 47 | if (file.timestamp > withinWeek) { 48 | stats.lastWeek++; 49 | transcriptionTime.lastWeek += file.processingData.uploadDurationInSeconds; 50 | } 51 | if (file.timestamp > withinMonth) { 52 | stats.lastMonth++; 53 | transcriptionTime.lastMonth += file.processingData.uploadDurationInSeconds; 54 | } 55 | stats.allTime++; 56 | transcriptionTime.allTime += file.processingData.uploadDurationInSeconds; 57 | 58 | if (file.processingData.language) { 59 | if (!languages[file.processingData.language]) { 60 | languages[file.processingData.language] = 1; 61 | } else { 62 | languages[file.processingData.language]++; 63 | } 64 | } 65 | } 66 | 67 | // l('files'); 68 | // l(files); 69 | 70 | transcriptionTime.lastHour = forHumansHoursAndMinutes(transcriptionTime.lastHour); 71 | transcriptionTime.last24h = forHumansHoursAndMinutes(transcriptionTime.last24h); 72 | transcriptionTime.lastWeek = forHumansHoursAndMinutes(transcriptionTime.lastWeek); 73 | transcriptionTime.lastMonth = forHumansHoursAndMinutes(transcriptionTime.lastMonth); 74 | transcriptionTime.allTime = forHumansHoursAndMinutes(transcriptionTime.allTime); 75 | 76 | // l('languages'); 77 | // l(languages); 78 | 79 | // sort languages by count 80 | const entries = Object.entries(languages); 81 | 82 | // Sort the array using the value 83 | entries.sort((a, b) => b[1] - a[1]); 84 | 85 | // Reconstruct the object 86 | const sortedObj = Object.fromEntries(entries); 87 | 88 | // l('sortedObj'); 89 | // l(sortedObj); 90 | 91 | // l('languages'); 92 | // l(languages); 93 | 94 | return res.render('stats/stats', { 95 | // list of file names 96 | title: 'Stats', 97 | stats, 98 | transcriptionTime, 99 | languages: sortedObj 100 | }); 101 | } catch (err) { 102 | l('err'); 103 | l(err); 104 | } 105 | }); 106 | 107 | module.exports = router; 108 | -------------------------------------------------------------------------------- /routes/transcribe.js: -------------------------------------------------------------------------------- 1 | const url = require('url'); 2 | const path = require('path'); 3 | const ffprobe = require('ffprobe'); 4 | const moment = require('moment/moment'); 5 | const Queue = require('promise-queue'); 6 | const multer = require('multer'); 7 | const express = require('express'); 8 | const router = express.Router(); 9 | const which = require('which'); 10 | const ffprobePath = which.sync('ffprobe') 11 | const fs = require('fs-extra'); 12 | 13 | const { downloadFile, getFilename } = require('../downloading/yt-dlp-download'); 14 | const transcribeWrapped = require('../transcribe/transcribe-wrapped'); 15 | const { languagesToTranslateTo } = require('../constants/constants'); 16 | const {forHumansNoSeconds} = require('../helpers/helpers'); 17 | const {makeFileNameSafe} = require('../lib/files'); 18 | const { addItemToQueue, getNumberOfPendingOrProcessingJobs } = require('../queue/queue'); 19 | const { addToJobProcessOrQueue, amountOfRunningJobs } = require('../queue/newQueue'); 20 | 21 | 22 | const nodeEnv = process.env.NODE_ENV || 'development'; 23 | const maxConcurrentJobs = Number(process.env.CONCURRENT_AMOUNT); 24 | const uploadLimitInMB = nodeEnv === 'production' ? Number(process.env.UPLOAD_FILE_SIZE_LIMIT_IN_MB) : 3000; 25 | 26 | l(`CONCURRENT JOBS ALLOWED AMOUNT: ${maxConcurrentJobs}`); 27 | 28 | const storage = multer.diskStorage({ // notice you are calling the multer.diskStorage() method here, not multer() 29 | destination: function (req, file, cb) { 30 | cb(null, './uploads/') 31 | }, 32 | }); 33 | 34 | let upload = multer({ storage }); 35 | 36 | router.post('/file', upload.single('file'), async function (req, res, next) { 37 | // l(global.ws); 38 | let websocketConnection; 39 | 40 | try { 41 | l(req.file); 42 | l(req.body); 43 | 44 | const referer = req.headers.referer; 45 | const urlObject = url.parse(referer); 46 | const pathname = urlObject.pathname; 47 | const isYtdlp = pathname === '/ytdlp'; 48 | 49 | l('isYtdlp'); 50 | l(isYtdlp); 51 | 52 | let language = req.body.language; 53 | let model = req.body.model; 54 | const websocketNumber = req.body.websocketNumber; 55 | const shouldTranslate = req.body.shouldTranslate === 'true'; 56 | const downloadLink = req.body.downloadLink; 57 | const { user, skipToFront, uploadTimeStarted } = req.body 58 | 59 | const passedFile = req.file; 60 | let downloadedFile = false; 61 | 62 | const uploadTimeFinished = new Date(); 63 | 64 | // this shouldn't happen but there's some sort of frontend bug 65 | if (!language || language === 'undefined' || language === 'Auto-Detect') { 66 | language = 'auto-detect'; 67 | } 68 | 69 | // make the model medium by default 70 | if (!model) { 71 | model = 'medium'; 72 | } 73 | 74 | if (model === 'tiny.en' || model === 'base.en' || model === 'small.en' || model === 'medium.en') { 75 | language = 'English' 76 | } 77 | 78 | let filename; 79 | 80 | l(downloadLink); 81 | 82 | function matchByWebsocketNumber (item) { 83 | return item.websocketNumber === websocketNumber; 84 | } 85 | 86 | // websocket number is pushed when it connects on page load 87 | // l(global.webSocketData); 88 | const websocket = global.webSocketData.find(matchByWebsocketNumber) 89 | if (websocket) { 90 | websocketConnection = websocket.websocket; 91 | } else { 92 | throw new Error('no websocket!'); 93 | } 94 | 95 | let originalFileNameWithExtension, uploadedFilePath, uploadGeneratedFilename; 96 | if (passedFile) { 97 | 98 | originalFileNameWithExtension = Buffer.from(req.file.originalname, 'latin1').toString('utf8'); 99 | uploadedFilePath = req.file.path; 100 | uploadGeneratedFilename = req.file.filename; 101 | l('uploadedFilePath'); 102 | l(uploadedFilePath); 103 | } else if (downloadLink) { 104 | 105 | websocketConnection.send(JSON.stringify({ 106 | message: 'downloadInfo', 107 | fileName: downloadLink, 108 | percentDownloaded: 0, 109 | }), function () {}); 110 | 111 | // TODO: not the world's greatest implemention 112 | function generateRandomNumber () { 113 | return Math.floor(Math.random() * 10000000000).toString(); 114 | } 115 | 116 | const randomNumber = generateRandomNumber(); 117 | 118 | filename = await getFilename(downloadLink); 119 | // remove linebreaks, this was causing bugs 120 | filename = filename.replace(/\r?\n|\r/g, ''); 121 | l('filename'); 122 | l(filename); 123 | uploadGeneratedFilename = filename; 124 | originalFileNameWithExtension = filename; 125 | const baseName = path.parse(filename).name; 126 | const extension = path.parse(filename).ext; 127 | uploadedFilePath = `uploads/${randomNumber}${extension}`; 128 | 129 | res.send('download'); 130 | 131 | // TODO: pass websocket connection and output download progress to frontend 132 | await downloadFile({ 133 | videoUrl: downloadLink, 134 | filepath: uploadedFilePath, 135 | randomNumber, 136 | websocketConnection, 137 | filename, 138 | websocketNumber, 139 | }); 140 | downloadedFile = true; 141 | 142 | uploadGeneratedFilename = baseName; 143 | 144 | } else { 145 | throw new Error('No file or download link provided'); 146 | // ERROR 147 | } 148 | 149 | l('uploadedFilePath'); 150 | l(uploadedFilePath); 151 | 152 | // get upload duration 153 | const ffprobeResponse = await ffprobe(uploadedFilePath, { path: ffprobePath }); 154 | 155 | const audioStream = ffprobeResponse.streams.filter(stream => stream.codec_type === 'audio')[0]; 156 | const uploadDurationInSeconds = Math.round(audioStream.duration); 157 | 158 | const stats = await fs.promises.stat(uploadedFilePath); 159 | const fileSizeInBytes = stats.size; 160 | const fileSizeInMB = Number(fileSizeInBytes / 1048576).toFixed(1); 161 | 162 | // TODO: pull out into a function 163 | // error if on FS and over file size limit or duration limit 164 | const domainName = req.hostname; 165 | 166 | const isFreeSubtitles = domainName === 'freesubtitles.ai'; 167 | if (isFreeSubtitles && !isYtdlp) { 168 | 169 | const amountOfSecondsInHour = 60 * 60; 170 | if (uploadDurationInSeconds > amountOfSecondsInHour) { 171 | const uploadLengthErrorMessage = `Your upload length is ${forHumansNoSeconds(uploadDurationInSeconds)}, but currently the maximum length allowed is only 1 hour`; 172 | return res.status(400).send(uploadLengthErrorMessage); 173 | } 174 | if (fileSizeInMB > uploadLimitInMB) { 175 | const uploadSizeErrorMessage = `Your upload size is ${fileSizeInMB} MB, but the maximum size currently allowed is ${uploadLimitInMB} MB.`; 176 | return res.status(400).send(uploadSizeErrorMessage); 177 | } 178 | } 179 | 180 | // TODO: pull into its own function 181 | /** WEBSOCKET FUNCTIONALITY **/ 182 | // load websocket by passed number 183 | 184 | 185 | const currentlyRunningJobs = amountOfRunningJobs(); 186 | const amountInQueue = global.newQueue.length 187 | const totalOutstanding = currentlyRunningJobs + amountInQueue - maxConcurrentJobs + 1; 188 | 189 | l('totaloutstanding'); 190 | l(totalOutstanding); 191 | 192 | /** WEBSOCKET FUNCTIONALITY END **/ 193 | 194 | const originalFileExtension = path.parse(originalFileNameWithExtension).ext; 195 | const originalFileNameWithoutExtension = path.parse(originalFileNameWithExtension).name; 196 | 197 | // directory name 198 | const directorySafeFileNameWithoutExtension = makeFileNameSafe(originalFileNameWithoutExtension) 199 | 200 | // used for the final media resting place 201 | const directorySafeFileNameWithExtension = `${directorySafeFileNameWithoutExtension}${originalFileExtension}` 202 | 203 | const timestampString = moment(new Date()).format('DD-MMMM-YYYY_HH_mm_ss'); 204 | 205 | const separator = '--' 206 | 207 | const fileSafeNameWithDateTimestamp = `${directorySafeFileNameWithoutExtension}${separator}${timestampString}`; 208 | 209 | const fileSafeNameWithDateTimestampAndExtension = `${directorySafeFileNameWithoutExtension}${separator}${timestampString}${originalFileExtension}`; 210 | 211 | // pass ip to queue 212 | const ip = req.headers['x-forwarded-for'] || 213 | req.socket.remoteAddress || 214 | null; 215 | 216 | // allow admin to see items in the queue 217 | addItemToQueue({ 218 | model, 219 | language, 220 | filename: originalFileNameWithExtension, 221 | ip, 222 | uploadDurationInSeconds, 223 | shouldTranslate, 224 | fileSizeInMB, 225 | startedAt: new Date(), 226 | status: 'pending', 227 | websocketNumber, 228 | ...(user && { user }), 229 | ...(downloadLink && { downloadLink }), 230 | ...(skipToFront && { skipToFront }), 231 | totalOutstanding, 232 | }) 233 | 234 | const transcriptionJobItem = { 235 | uploadedFilePath, 236 | language, 237 | model, 238 | directorySafeFileNameWithoutExtension, 239 | directorySafeFileNameWithExtension, 240 | originalFileNameWithExtension, 241 | fileSafeNameWithDateTimestamp, 242 | fileSafeNameWithDateTimestampAndExtension, 243 | uploadGeneratedFilename, 244 | shouldTranslate, 245 | uploadDurationInSeconds, 246 | fileSizeInMB, 247 | ...(user && { user }), 248 | ...(downloadLink && { downloadLink }), 249 | skipToFront: skipToFront === 'true', 250 | totalOutstanding, 251 | ip, 252 | 253 | // websocket/queue 254 | websocketConnection, 255 | websocketNumber, 256 | languagesToTranslateTo, 257 | } 258 | 259 | // l('transcriptionJobItem'); 260 | // l(transcriptionJobItem); 261 | addToJobProcessOrQueue(transcriptionJobItem); 262 | 263 | const obj = JSON.parse(JSON.stringify(req.body)); 264 | l(obj); 265 | 266 | // l(req.files); 267 | 268 | // assuming already sent from above 269 | if (!downloadedFile) { 270 | res.send('ok'); 271 | } 272 | // req.files is array of uploaded files 273 | // req.body will contain the text fields, if there were any 274 | } catch (err) { 275 | l('err from transcribe route') 276 | l(err); 277 | 278 | // websocketConnection.terminate() 279 | // throw (err); 280 | } 281 | }); 282 | 283 | router.get('/checkingOutstandingProcesses', async function (req, res, next) { 284 | const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || null; 285 | 286 | const outstandingJobsAmount = getNumberOfPendingOrProcessingJobs(ip); 287 | 288 | l('outstandingJobsAmount'); 289 | l(outstandingJobsAmount); 290 | 291 | if (outstandingJobsAmount >= 3) { 292 | res.send('tooMany'); 293 | } else { 294 | res.send('ok'); 295 | } 296 | 297 | try { 298 | 299 | } catch (err) { 300 | l('err from transcribe route') 301 | l(err); 302 | 303 | // websocketConnection.terminate() 304 | // throw (err); 305 | } 306 | }); 307 | 308 | module.exports = router; 309 | -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | /* GET users listing. */ 5 | router.get('/', function (req, res, next) { 6 | res.send('respond with a resource'); 7 | }); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /scripts/deleteTranscriptionUploads.js: -------------------------------------------------------------------------------- 1 | const {tr} = require('language-name-map/map'); 2 | let l = console.log; 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | 6 | const mediaFileExtensions = [ 7 | // Audio file extensions 8 | '.aac', 9 | '.ac3', 10 | '.adts', 11 | '.aif', 12 | '.aiff', 13 | '.aifc', 14 | '.amr', 15 | '.au', 16 | '.awb', 17 | '.dct', 18 | '.dss', 19 | '.dvf', 20 | '.flac', 21 | '.gsm', 22 | '.m4a', 23 | '.m4p', 24 | '.mmf', 25 | '.mp3', 26 | '.mpc', 27 | '.msv', 28 | '.oga', 29 | '.ogg', 30 | '.opus', 31 | '.ra', 32 | '.ram', 33 | '.raw', 34 | '.sln', 35 | '.tta', 36 | '.vox', 37 | '.wav', 38 | '.wma', 39 | 40 | // Video file extensions 41 | '.3g2', 42 | '.3gp', 43 | '.3gpp', 44 | '.asf', 45 | '.avi', 46 | '.dat', 47 | '.flv', 48 | '.m2ts', 49 | '.m4v', 50 | '.mkv', 51 | '.mod', 52 | '.mov', 53 | '.mp4', 54 | '.mpe', 55 | '.mpeg', 56 | '.mpg', 57 | '.mts', 58 | '.ogv', 59 | '.qt', 60 | '.rm', 61 | '.rmvb', 62 | '.swf', 63 | '.ts', 64 | '.vob', 65 | '.webm', 66 | '.wmv' 67 | ]; 68 | 69 | 70 | // get argument from command line 71 | const shouldDeleteFiles = process.argv[2] === 'delete'; 72 | const logDeleteOnly = process.argv[2] === 'toDelete'; 73 | const logKeepOnly = process.argv[2] === 'toKeep'; 74 | 75 | function logInBlueColor (message) { 76 | console.log(`\x1b[34m${message}\x1b[0m`); 77 | } 78 | 79 | function logInRedColor (message) { 80 | console.log(`\x1b[31m${message}\x1b[0m`); 81 | } 82 | 83 | if (logDeleteOnly) { 84 | logInRedColor = function () {}; // disable logging 85 | } 86 | if (logKeepOnly) { 87 | logInBlueColor = function () {}; // disable logging 88 | } 89 | 90 | async function deleteAllMediaFiles ({ dirPath }) { 91 | // Get an array of all the files in the directory 92 | const files = await fs.promises.readdir(dirPath); 93 | 94 | // Loop through all the files in the directory 95 | for (const file of files) { 96 | // get file extension using path module 97 | const fileExtension = path.extname(file); 98 | 99 | if (mediaFileExtensions.includes(fileExtension)) { 100 | try { 101 | await fs.promises.unlink(`${dirPath}/${file}`); 102 | } catch (error) { 103 | l('error'); 104 | l(error); 105 | console.error(`Error deleting file: ${dirPath}/${file}`); 106 | } 107 | 108 | } 109 | } 110 | } 111 | 112 | async function findMediaFileInDirectory (directory) { 113 | const files = await fs.promises.readdir(directory); 114 | for (const file of files) { 115 | // get file extension using path module 116 | const fileExtension = path.extname(file); 117 | if (mediaFileExtensions.includes(fileExtension)) { 118 | return file; 119 | } 120 | } 121 | return false 122 | } 123 | 124 | 125 | // delete files that are over 24h old script 126 | const deleteOldFiles = async function (shouldDelete = false) { 127 | try { 128 | 129 | let deletingFiles = shouldDelete || shouldDeleteFiles; 130 | 131 | const processDirectory = process.cwd(); 132 | const transcriptionsDirectory = `${processDirectory}/transcriptions`; 133 | const transcriptionsDirectoryContents = await fs.readdir(transcriptionsDirectory); 134 | 135 | let totalFileSizeToDelete = 0; 136 | 137 | // loop through all transcription directories 138 | for (const transcriptionDirectory of transcriptionsDirectoryContents) { 139 | // check if directory is directory 140 | const directoryPath = `${transcriptionsDirectory}/${transcriptionDirectory}`; 141 | 142 | // this is guaranteed to exist 143 | const directoryStats = await fs.stat(directoryPath); 144 | 145 | const isDirectory = directoryStats.isDirectory(); 146 | 147 | // only loop through if it's a directory 148 | if (isDirectory) { 149 | // check if directory is empty 150 | const directoryContents = await fs.readdir(directoryPath); 151 | 152 | // get the name of the media file if it exists 153 | const mediaFile = await findMediaFileInDirectory(directoryPath); 154 | 155 | // get the path to the media 156 | const mediaFilePath = `${directoryPath}/${mediaFile}`; 157 | 158 | // no media to delete, keep going 159 | if (!mediaFile) { 160 | continue; 161 | } 162 | 163 | // check if directory has a processing_data.json file 164 | const processingDataPath = `${directoryPath}/processing_data.json`; 165 | 166 | // read processing_data.json file 167 | // dont error if processingData doesn't exist 168 | const processingDataExists = await fs.pathExists(processingDataPath); 169 | 170 | // TODO: only implement when it's ready 171 | if (!processingDataExists) { 172 | l('deleting media files') 173 | // await fs.unlink(mediaFilePath); 174 | continue 175 | } 176 | 177 | let processingData, fileExistsButJsonError; 178 | try { 179 | processingData = JSON.parse(await fs.readFile(processingDataPath, 'utf8')); 180 | } catch (err) { 181 | 182 | // syntax error 183 | fileExistsButJsonError = err.toString().includes('SyntaxError'); 184 | 185 | // delete the media if json error 186 | if (fileExistsButJsonError) { 187 | l('deleting media files') 188 | // delete the media files 189 | if (deletingFiles) { 190 | await deleteAllMediaFiles({ dirPath: directoryPath }); 191 | } 192 | continue 193 | } 194 | } 195 | 196 | // TODO: could have side effects until data saving lands 197 | if (!processingData) { 198 | l('no processing data'); 199 | l('deleting media files') 200 | // await deleteAllMediaFiles({ dirPath: directoryPath }); 201 | continue 202 | } 203 | 204 | // check if processing data keep media property is true 205 | const shouldKeepMedia = processingData.keepMedia; 206 | 207 | // if keep media is true, keep going 208 | if (shouldKeepMedia) { 209 | l('should keep'); 210 | continue; 211 | } 212 | 213 | // check if processing_data.json file has a completedAt property 214 | if (processingData.startedAt) { 215 | // check if completedAt is over 24h old 216 | const startedAt = new Date(processingData.startedAt); 217 | const now = new Date(); 218 | const difference = now - startedAt; 219 | const hoursDifference = difference / 1000 / 60 / 60; 220 | 221 | const over24Hours = hoursDifference > 24; 222 | 223 | if (over24Hours) { 224 | l('deleting media files') 225 | if (deletingFiles && !shouldKeepMedia) { 226 | // delete mediaFilePath 227 | await fs.unlink(mediaFilePath); 228 | } 229 | } else { 230 | l('not over 24 hours'); 231 | } 232 | 233 | // there is an issue because the current processing_data.json file doesn't have a startedAt property 234 | } else { 235 | l('deleting media files') 236 | if (deletingFiles) await deleteAllMediaFiles({ dirPath: directoryPath }); 237 | } 238 | } 239 | 240 | // l('transcriptionsDirectoryContents'); 241 | // l(transcriptionsDirectoryContents); 242 | } 243 | 244 | logInBlueColor('totalFileSizeToDelete'); 245 | logInBlueColor(totalFileSizeToDelete); 246 | } catch (err) { 247 | l('err'); 248 | l(err); 249 | l(err.stack); 250 | } 251 | } 252 | 253 | // deleteOldFiles(); 254 | 255 | module.exports = { 256 | deleteOldFiles 257 | } 258 | -------------------------------------------------------------------------------- /scripts/extractAudioFfmpeg.js: -------------------------------------------------------------------------------- 1 | const spawn = require('child_process').spawn; 2 | const which = require('which'); 3 | const ffprobe = require('ffprobe'); 4 | 5 | const l = console.log; 6 | 7 | const ffmpegPath = which.sync('ffmpeg') 8 | 9 | const inputVideoPath = './trimmed.mp4'; 10 | 11 | // ffmpeg -i input-video.avi -vn -acodec copy output-audio.aac 12 | 13 | 14 | const ffprobePath = which.sync('ffprobe') 15 | 16 | l(process.cwd()) 17 | 18 | // return 19 | 20 | function extractAudio (inputVideoPath, outputAudioPath) { 21 | return new Promise((resolve, reject) => { 22 | const ffmpegArguments = [ 23 | '-i', inputVideoPath, // input video path 24 | '-y', // overwrite output file if it exists 25 | '-vn', // no video 26 | '-acodec', 'copy', // copy audio codec (don't re-encode) 27 | `./${outputAudioPath}` 28 | ] 29 | 30 | const ffmpegProcess = spawn(ffmpegPath, ffmpegArguments); 31 | 32 | // TODO: implement foundLanguagae here 33 | // let foundLanguage; 34 | ffmpegProcess.stdout.on('data', (data) => { 35 | l(`STDOUT: ${data}`) 36 | }); 37 | 38 | /** console output from stderr **/ // (progress comes through stderr for some reason) 39 | ffmpegProcess.stderr.on('data', (data) => { 40 | l(`STDERR: ${data}`) 41 | }); 42 | 43 | /** whisper responds with 0 or 1 process code **/ 44 | ffmpegProcess.on('close', (code) => { 45 | l(`child process exited with code ${code}`); 46 | if (code === 0) { 47 | resolve(); 48 | } else { 49 | reject() 50 | } 51 | }); 52 | }) 53 | } 54 | 55 | async function getAudioCodec () { 56 | const ffprobeResponse = await ffprobe(inputVideoPath, { path: ffprobePath }); 57 | 58 | l(ffprobeResponse); 59 | 60 | // get audio stream 61 | const audioStream = ffprobeResponse.streams.find(stream => stream.codec_type === 'audio'); 62 | 63 | // get audio codec 64 | const audioCodec = audioStream.codec_name; 65 | 66 | // like .aac 67 | return audioCodec 68 | } 69 | 70 | async function main () { 71 | try { 72 | const audioCodec = await getAudioCodec(); 73 | await extractAudio(inputVideoPath, `output-audio.${audioCodec}`); 74 | } catch (err) { 75 | l('err'); 76 | l(err); 77 | } 78 | } 79 | 80 | main(); 81 | -------------------------------------------------------------------------------- /scripts/postAudioFile.js: -------------------------------------------------------------------------------- 1 | const FormData = require('form-data'); 2 | const fs = require('fs-extra'); 3 | const axios = require('axios'); 4 | 5 | const l = console.log; 6 | 7 | // TODO: should be able to hit any remote API 8 | // TODO load it in like a list 9 | const endpointToHit = 'http:localhost:3001/api' 10 | 11 | function generateRandomNumber () { 12 | return Math.floor(Math.random() * 10000000000).toString(); 13 | } 14 | 15 | async function hitRemoteApiEndpoint (form, fullApiEndpoint) { 16 | // use passed if available 17 | const endpointToUse = fullApiEndpoint || endpointToHit; 18 | 19 | const response = await axios.post(endpointToUse, form, { 20 | headers: { 21 | ...form.getHeaders(), 22 | }, 23 | }); 24 | 25 | return response 26 | } 27 | 28 | async function getNewData (dataUrl) { 29 | let dataResponse = await axios.get(dataUrl); 30 | 31 | l('dataResponse'); 32 | l(dataResponse.data); 33 | return dataResponse.data 34 | } 35 | 36 | function checkResponse (dataResponse) { 37 | const transcriptionStatus = dataResponse?.status; 38 | 39 | const transcriptionComplete = transcriptionStatus === 'completed'; 40 | const transcriptionErrored = transcriptionStatus === 'errored'; 41 | 42 | const transcriptionIsTranslating = transcriptionStatus === 'translating'; 43 | 44 | const transcriptionIsProcessing = transcriptionStatus === 'starting-transcription' || 45 | transcriptionStatus === 'transcribing' || transcriptionStatus === 'processing' || transcriptionIsTranslating; 46 | 47 | if (transcriptionComplete) { 48 | const transcription = dataResponse?.transcription; 49 | const sdHash = dataResponse?.sdHash; 50 | const subtitles = dataResponse?.subtitles; 51 | const processingData = dataResponse?.processingData; 52 | 53 | return { 54 | status: 'completed', 55 | transcription, 56 | sdHash, 57 | subtitles, 58 | processingData, 59 | } 60 | } 61 | 62 | if (transcriptionIsProcessing) { 63 | const percentDone = dataResponse?.processingData?.progress; 64 | return { 65 | status: 'processing', 66 | percentDone 67 | } 68 | } 69 | 70 | if (transcriptionErrored) { 71 | return 'failed' 72 | } 73 | 74 | return false 75 | } 76 | 77 | 78 | const machineApiKey = ''; 79 | 80 | /** 81 | * Transcribe a file on a remote server 82 | * @param pathToAudioFile 83 | * @param language 84 | * @param model 85 | * @param websocketNumber 86 | */ 87 | async function transcribeRemoteServer (pathToAudioFile, language, model, websocketNumber, fullApiEndpoint) { 88 | // Create a new form instance 89 | const form = new FormData(); 90 | 91 | // add the audio to the form as 'file' 92 | form.append('file', fs.createReadStream(pathToAudioFile)); 93 | 94 | // load in language, model, and websocket number (which we have from the frontend) 95 | form.append('language', language); 96 | form.append('model', model); 97 | form.append('websocketNumber', websocketNumber); 98 | 99 | const response = await hitRemoteApiEndpoint(form, fullApiEndpoint); 100 | 101 | l('response'); 102 | l(response); 103 | 104 | const dataEndpoint = response.data.transcribeDataEndpoint; 105 | 106 | return dataEndpoint; 107 | 108 | } 109 | 110 | const delayPromise = (delayTime) => { 111 | return new Promise((resolve) => { 112 | setTimeout(() => { 113 | resolve(); 114 | }, delayTime); 115 | }); 116 | }; 117 | 118 | async function getResult (dataEndpoint) { 119 | let dataResponse = await getNewData(dataEndpoint); 120 | let response = checkResponse(dataResponse); 121 | 122 | l('response'); 123 | l(response); 124 | 125 | if (response.status === 'failed') { 126 | l('detected that failed') 127 | return { 128 | status: 'failed' 129 | } 130 | } else if (response.status === 'completed') { 131 | 132 | l('detected that completed') 133 | return { 134 | status: 'completed' 135 | // TODO: attach all the data 136 | } 137 | } else { 138 | l('detected that processing') 139 | await delayPromise(5000); 140 | return await getResult(dataEndpoint); 141 | } 142 | } 143 | 144 | /*** 145 | * Allows a frontend to transcribe to via the API of a remote server 146 | * @param filePath 147 | * @param language 148 | * @param model 149 | * @param websocketNumber 150 | * @param fullApiEndpoint 151 | * @returns {Promise} 152 | */ 153 | async function transcribeViaRemoteApi ({ filePath, language, model, websocketNumber, remoteServerApiUrl }) { 154 | const dataEndpoint = await transcribeRemoteServer(filePath, language, model, websocketNumber, remoteServerApiUrl); 155 | 156 | return await getResult(dataEndpoint) 157 | } 158 | 159 | async function realMain () { 160 | const filePath = './output-audio.aac'; 161 | const language = 'Serbian'; 162 | const model = 'tiny'; 163 | const websocketNumber = generateRandomNumber() 164 | const remoteServerApiUrl = 'http://localhost:3001/api' 165 | const response = await transcribeViaRemoteApi({ 166 | filePath, 167 | language, 168 | model, 169 | websocketNumber, 170 | remoteServerApiUrl 171 | }); 172 | l('completed response'); 173 | l(response); 174 | } 175 | 176 | realMain() 177 | 178 | // main(); -------------------------------------------------------------------------------- /scripts/srtToVtt.js: -------------------------------------------------------------------------------- 1 | let fs = require('fs-extra') 2 | let convert = require('cyrillic-to-latin') 3 | // const srt2vtt = Promise.promisifyAll(require('srt2vtt')); 4 | const srt2vtt = require('srt2vtt') 5 | 6 | const path = './public/translated.srt' 7 | 8 | const filename = 'serbian'; 9 | 10 | l = console.log; 11 | 12 | l(srt2vtt) 13 | 14 | 15 | // function createTicket(ticket) { 16 | // // 1 - Create a new Promise 17 | // return new Promise(function (resolve, reject) { 18 | // // 2 - Copy-paste your code inside this function 19 | // client.tickets.create(ticket, function (err, req, result) { 20 | // // 3 - in your async function's callback 21 | // // replace return by reject (for the errors) and resolve (for the results) 22 | // if (err) { 23 | // reject(err); 24 | // } else { 25 | // resolve(JSON.stringify(result)); 26 | // } 27 | // }); 28 | // }); 29 | // } 30 | 31 | function converSrtToVtt () { 32 | 33 | } 34 | 35 | async function main () { 36 | let data = await fs.readFile(path, 'utf8'); 37 | 38 | data = convert(data); 39 | 40 | // l(data); 41 | // data = await srt2vtt(data); 42 | 43 | srt2vtt(data, async function (err, vttData) { 44 | l('running here'); 45 | 46 | l(vttData); 47 | 48 | // vttData = convert(vttData); 49 | 50 | // l(vttData); 51 | 52 | if (err) throw new Error(err); 53 | l(data); 54 | 55 | fs.writeFileSync('./public/redone.vtt', vttData); 56 | }); 57 | 58 | // l(data); 59 | // data = await convert(data); 60 | // l(data); 61 | // await fs.writeFile(data, `${path}/${filename}.mp4.vtt`, 'utf8') 62 | } 63 | 64 | main(); 65 | // 66 | // fs.createReadStream(`${path}/${filename}.mp4.srt`) 67 | // .pipe(srt2vtt()) 68 | // .pipe(convert()) 69 | // .pipe(fs.createWriteStream(`${path}/${filename}.mp4.vtt`)) 70 | -------------------------------------------------------------------------------- /transcribe/transcribe-api-wrapped.js: -------------------------------------------------------------------------------- 1 | const which = require('which'); 2 | const spawn = require('child_process').spawn; 3 | const { handleStdErr, handleStdOut, handleProcessClose } = require('../lib/transcribing') 4 | 5 | const { 6 | buildArguments, 7 | } = require('./transcribing'); 8 | 9 | l = console.log; 10 | 11 | const whisperPath = which.sync('whisper') 12 | 13 | async function transcribe ({ 14 | language, 15 | model, 16 | originalFileExtension, 17 | uploadFileName, 18 | originalFileName, 19 | uploadFilePath, 20 | numberToUse, // random or websocket number (websocket if being used from frontend) 21 | }) { 22 | return new Promise(async (resolve, reject) => { 23 | try { 24 | // where app.js is running from 25 | const processDir = process.cwd() 26 | 27 | // original upload file path 28 | const originalUpload = `${processDir}/uploads/${uploadFileName}`; 29 | 30 | // 31 | const processingDataPath = `${processDir}/transcriptions/${numberToUse}/processing_data.json` 32 | 33 | // save date when starting to see how long it's taking 34 | const startingDate = new Date(); 35 | l(startingDate); 36 | 37 | const whisperArguments = buildArguments({ 38 | uploadedFilePath: uploadFilePath, // file to use 39 | language, // 40 | model, 41 | numberToUse, 42 | }) 43 | 44 | l('whisperArguments'); 45 | l(whisperArguments); 46 | 47 | // start whisper process 48 | const whisperProcess = spawn(whisperPath, whisperArguments); 49 | 50 | // TODO: implement foundLanguagae here 51 | // let foundLanguage; 52 | whisperProcess.stdout.on('data', (data) => l(`STDOUT: ${data}`)); 53 | 54 | /** console output from stderr **/ // (progress comes through stderr for some reason) 55 | whisperProcess.stderr.on('data', handleStdErr({ model, language, originalFileName, processingDataPath })); 56 | 57 | /** whisper responds with 0 or 1 process code **/ 58 | whisperProcess.on('close', handleProcessClose({ processingDataPath, originalUpload, numberToUse })) 59 | 60 | 61 | } catch (err) { 62 | l('error from transcribe') 63 | l(err); 64 | 65 | reject(err); 66 | 67 | throw new Error(err) 68 | } 69 | 70 | }); 71 | 72 | } 73 | 74 | module.exports = transcribe; 75 | -------------------------------------------------------------------------------- /transcribe/transcribe-via-api.js: -------------------------------------------------------------------------------- 1 | const servers = [{ 2 | sslHostnameAndPort : 'localhost:8080', 3 | processAmount: 2, 4 | }] 5 | 6 | async function startTranscription () { 7 | const audioFile = './ljubav.mp3'; 8 | 9 | 10 | } 11 | 12 | async function transcribeViaApi () { 13 | const audioFile = './ljubav.mp3'; 14 | 15 | 16 | 17 | } -------------------------------------------------------------------------------- /transcribe/transcribing.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const createTranslatedFiles = require('../translate/create-translated-files'); 3 | const {forHumans} = require('../helpers/helpers'); 4 | 5 | // TODO: move to another directory 6 | const outputFileExtensions = ['.srt', '.vtt', '.txt'] 7 | 8 | const nodeEnvironment = process.env.NODE_ENV; 9 | const libreTranslateHostPath = process.env.LIBRETRANSLATE; 10 | 11 | const isProd = nodeEnvironment === 'production'; 12 | 13 | function buildArguments ({ 14 | uploadedFilePath, 15 | language, 16 | model, 17 | numberToUse 18 | }) { 19 | /** INSTANTIATE WHISPER PROCESS **/ 20 | // queue up arguments, path is the first one 21 | let arguments = []; 22 | 23 | // first argument is path to file 24 | arguments.push(uploadedFilePath); 25 | 26 | // these don't have to be defined 27 | if (language) arguments.push('--language', language); 28 | if (model) arguments.push('--model', model); 29 | 30 | // dont show the text output but show the progress thing 31 | arguments.push('--verbose', 'False'); 32 | 33 | // folder to save .txt, .vtt and .srt 34 | arguments.push('-o', `transcriptions/${numberToUse}`); 35 | 36 | l('transcribe arguments'); 37 | l(arguments); 38 | 39 | return arguments 40 | } 41 | 42 | async function translateIfNeeded ({ language, shouldTranslate, processingDataPath, directoryAndFileName}) { 43 | const shouldTranslateFromLanguage = shouldTranslateFrom(language); 44 | l(`should translate from language: ${shouldTranslateFromLanguage}`) 45 | l(`libreTranslateHostPath: ${libreTranslateHostPath}`) 46 | l(`should translate: ${shouldTranslate}`) 47 | 48 | let translationStarted, translationFinished = false; 49 | /** AUTOTRANSLATE WITH LIBRETRANSLATE **/ 50 | if (libreTranslateHostPath && shouldTranslateFromLanguage && shouldTranslate) { 51 | l('hitting LibreTranslate'); 52 | translationStarted = new Date(); 53 | // hit libretranslate 54 | await createTranslatedFiles({ 55 | directoryAndFileName, 56 | language, 57 | }) 58 | 59 | await writeToProcessingDataFile(processingDataPath, { 60 | translationStartedAt: new Date(), 61 | status: 'translating', 62 | }) 63 | } 64 | } 65 | 66 | async function saveTranscriptionCompletedInformation ({ 67 | startingDate, 68 | sdHash 69 | }) { 70 | const processingDataPath = `./transcriptions/${sdHash}/processing_data.json`; 71 | 72 | // just post-processing, you can return the response 73 | const processingSeconds = Math.round((new Date() - startingDate) / 1000); 74 | 75 | await writeToProcessingDataFile(processingDataPath, { 76 | processingSeconds, 77 | processingSecondsHumanReadable: forHumans(processingSeconds), 78 | startedAt: startingDate.toUTCString(), 79 | finishedAT: new Date().toUTCString(), 80 | }) 81 | } 82 | 83 | async function moveAndRenameFilesAndFolder ({ 84 | originalUpload, 85 | uploadFileName, 86 | sdHash, 87 | originalFileExtension, 88 | }) { 89 | const originalUploadPath = originalUpload; 90 | 91 | // the directory with the output from whisper 92 | let currentContainingDir = `./transcriptions/${sdHash}`; 93 | 94 | const newUploadPath = `${currentContainingDir}/${sdHash}${originalFileExtension}` 95 | 96 | // rename original upload to use the original file upload name 97 | await fs.move(originalUploadPath, newUploadPath) 98 | 99 | // move each of the different output files 100 | for (const fileExtension of outputFileExtensions) { 101 | // create the prepend thing to loop over 102 | const transcribedFilePath = `${currentContainingDir}/${uploadFileName}${fileExtension}` 103 | const newTranscribedFilePath = `${currentContainingDir}/${sdHash}${fileExtension}` 104 | 105 | // rename 106 | await fs.move(transcribedFilePath, newTranscribedFilePath) 107 | } 108 | 109 | // rename containing dir to the safe filename (from upload filename) 110 | // const renamedDirectory = `./transcriptions/${sixDigitNumber}`; 111 | // await fs.rename(currentContainingDir, renamedDirectory) 112 | } 113 | 114 | module.exports = { 115 | moveAndRenameFilesAndFolder, 116 | saveTranscriptionCompletedInformation, 117 | translateIfNeeded, 118 | buildArguments, 119 | // autoDetectLanguage, 120 | // writeToProcessingDataFile 121 | } 122 | -------------------------------------------------------------------------------- /translate/create-translated-files.js: -------------------------------------------------------------------------------- 1 | // const translateText = require('./libreTranslateWrapper'); 2 | const fs = require('fs-extra'); 3 | // TODO: this is named wrong, should be languagesToTranslateTo 4 | const { languagesToTranscribe, allLanguages } = require('../constants/constants');; 5 | const { reformatVtt } = require('./helpers') 6 | const { simplified } = require('zh-convert'); 7 | const translateText = require('./google-translate-browser') 8 | 9 | const convert = require('cyrillic-to-latin'); 10 | 11 | let l = console.log; 12 | 13 | if (global.debug === 'false') { 14 | l = function () {} 15 | } 16 | 17 | // l('translationLanguages') 18 | // l(translationLanguages); 19 | // 20 | // l('languagesToTranscribe') 21 | // l(languagesToTranscribe); 22 | 23 | // l('all languages'); 24 | // l(allLanguages); 25 | 26 | function getCodeFromLanguageName (languageName) { 27 | return allLanguages.find(function (filteredLanguage) { 28 | return languageName === filteredLanguage.name; 29 | }).code 30 | } 31 | 32 | // l(getCodeFromLanguageName('English')) 33 | // TODO: pass processing path 34 | /** for translation **/ 35 | async function createTranslatedFiles ({ 36 | directoryAndFileName, 37 | language, 38 | websocketConnection, 39 | strippedText, 40 | timestampsArray, 41 | }) { 42 | 43 | const loopThrough = ['.srt', '.vtt', 'txt']; 44 | 45 | const vttPath = `${directoryAndFileName}.vtt`; 46 | 47 | // TODO: translate the rest 48 | const vttData = await fs.readFile(vttPath, 'utf-8'); 49 | l('vttData'); 50 | l(vttData); 51 | 52 | // TODO: pass this in from controller? 53 | // const { strippedText, timestampsArray } = await stripOutTextAndTimestamps(vttPath) 54 | 55 | // save stripped and timestamps to processing data 56 | 57 | l({languagesToTranscribe}) 58 | 59 | 60 | for (const languageToConvertTo of languagesToTranscribe) { 61 | l('languageToConvertTo'); 62 | l(languageToConvertTo); 63 | 64 | l('language'); 65 | l(language); 66 | 67 | try { 68 | // no need to translate just copy the file 69 | if (languageToConvertTo !== language) { 70 | websocketConnection.send(JSON.stringify({ 71 | languageUpdate: `Translating into ${languageToConvertTo}..`, 72 | message: 'languageUpdate' 73 | }), function () {}); 74 | 75 | 76 | const sourceLanguageCode = getCodeFromLanguageName(language); 77 | const targetLanguageCode = getCodeFromLanguageName(languageToConvertTo); 78 | 79 | // l('sourceLanguageCode'); 80 | // l(sourceLanguageCode); 81 | // l('targetLanguageCode'); 82 | // l(targetLanguageCode); 83 | 84 | // hit LibreTranslate backend 85 | l(`hitting libretranslate: ${language} -> ${languageToConvertTo}`); 86 | // TODO: to convert to thing 87 | // let translatedText = await translateText({ 88 | // sourceLanguage: sourceLanguageCode, // before these were like 'EN', will full language work? 89 | // targetLanguage: targetLanguageCode, 90 | // text: strippedText, 91 | // }) 92 | 93 | let translatedText = await translateText({ 94 | text: strippedText, 95 | targetLanguageCode, // before these were like 'EN', will full language work? 96 | }) 97 | // l('translatedText'); 98 | // l(translatedText); 99 | 100 | if (!translatedText) { 101 | continue 102 | } 103 | 104 | if (languageToConvertTo === 'Chinese') { 105 | translatedText = simplified(translatedText); 106 | } 107 | 108 | if (languageToConvertTo === 'Serbian') { 109 | translatedText = convert(translatedText); 110 | } 111 | 112 | const reformattedVtt = reformatVtt(timestampsArray, translatedText); 113 | 114 | await fs.writeFile(`${directoryAndFileName}_${languageToConvertTo}.vtt`, reformattedVtt, 'utf-8'); 115 | } 116 | } catch (err) { 117 | l(err); 118 | l('error in translation'); 119 | return err 120 | } 121 | } 122 | } 123 | 124 | // const uploadDirectoryName = 'ef56767d5cba0ae421a9f6f570443205'; 125 | // const transcribedFileName = 'ef56767d5cba0ae421a9f6f570443205'; 126 | // 127 | // const languageToConvertFrom = 'en'; 128 | // const languagesToConvertTo = ['es', 'fr']; 129 | 130 | // async function main(){ 131 | // const completed = await createTranslatedSrts({ 132 | // uploadDirectoryName: uploadDirectoryName, 133 | // transcribedFileName: transcribedFileName, 134 | // languageToConvertFrom: languageToConvertFrom, 135 | // languagesToConvertTo: languagesToConvertTo 136 | // }); 137 | // 138 | // l('completed'); 139 | // l(completed); 140 | // } 141 | 142 | // main(); 143 | 144 | module.exports = createTranslatedFiles; 145 | 146 | -------------------------------------------------------------------------------- /translate/google-translate-browser.js: -------------------------------------------------------------------------------- 1 | const { generateRequestUrl, normaliseResponse } = require('google-translate-api-browser'); 2 | const axios = require('axios'); 3 | 4 | const l = console.log; 5 | 6 | const maximumStringLength = 3000; 7 | 8 | function splitString (str) { 9 | let splitStrings = []; 10 | let currentString = ''; 11 | const splitString = str.split('\n'); 12 | 13 | l('total lines') 14 | l(splitString.length); 15 | 16 | // TODO: if this was smarter it wouldn't go over 5000 characters, 17 | // TODO: but it seems to be fine for now 18 | let counter = 0; 19 | for (const string of splitString) { 20 | counter++ 21 | // we have yet to encounter the maximum string length 22 | if (currentString.length < maximumStringLength) { 23 | // l('not too long') 24 | currentString = `${currentString}${string}\n` 25 | 26 | 27 | if (counter === splitString.length) { 28 | splitStrings.push(currentString); 29 | } 30 | // currentString = currentString + string + '\n'; 31 | } else { 32 | l('too long') 33 | currentString = `${currentString}${string}\n` 34 | splitStrings.push(currentString); 35 | currentString = ''; 36 | } 37 | } 38 | return splitStrings; 39 | } 40 | 41 | function test () { 42 | const textToTranslate = `Znači tako. 43 | Tako. 44 | Vi znate šta vam sleduje? 45 | Nemamo pojma, to ne piše nigde u pravilniku. 46 | Od sad ćete razgovarati sa višim instansama. 47 | Ko je idejni vođa ovog protesta? 48 | Ja sam. 49 | Pa, šta da kažem. 50 | Možda i nije najpametnije što sam to uradila, ali... 51 | ` 52 | 53 | const chunks = splitString(textToTranslate); 54 | 55 | l('chunks'); 56 | l(chunks.length); 57 | // for(const chunk of chunks) { 58 | // l(chunk); 59 | // } 60 | // l(chunks); 61 | 62 | let recombined = ''; 63 | for (const chunk of chunks) { 64 | recombined = recombined + chunk; 65 | } 66 | 67 | l('recombined'); 68 | l(recombined.length); 69 | // l(recombined) 70 | 71 | // l(textToTranslate) 72 | 73 | // const chunks = splitString(textToTranslate); 74 | // 75 | // l('chunks', chunks); 76 | 77 | const resplit = recombined.split('\n'); 78 | l('resplit'); 79 | l(resplit.length); 80 | // l(textToTranslate.split('\n').length)); 81 | } 82 | 83 | 84 | 85 | async function translateText ({ text, targetLanguageCode }) { 86 | const url = generateRequestUrl(text, { to: targetLanguageCode }); 87 | // l('generated url'); 88 | // l(url); 89 | 90 | let response; 91 | try { 92 | response = await axios.get(url); 93 | 94 | const parsedResponse = normaliseResponse(response.data); 95 | // l('parsedResponse'); 96 | // l(parsedResponse); 97 | 98 | const translatedText = parsedResponse.text; 99 | // l('translatedText'); 100 | // l(translatedText); 101 | 102 | return translatedText; 103 | } catch (error) { 104 | l('errored'); 105 | // l('error', error); 106 | } 107 | } 108 | 109 | async function splitAndTranslateText ({ text, targetLanguageCode }) { 110 | const chunks = splitString(text); 111 | l('chunks'); 112 | l(chunks.length); 113 | 114 | let translatedText = ''; 115 | for (const chunk of chunks) { 116 | const translatedChunk = await translateText({ text: chunk, targetLanguageCode }); 117 | translatedText = translatedText + translatedChunk + '\n'; 118 | } 119 | 120 | return translatedText; 121 | } 122 | 123 | function howManyLines (string) { 124 | return string.split('\n').length; 125 | } 126 | 127 | const delayPromise = (delayTime) => { 128 | return new Promise((resolve) => { 129 | setTimeout(() => { 130 | resolve(); 131 | }, delayTime); 132 | }); 133 | }; 134 | 135 | async function newMain () { 136 | await delayPromise(1000); 137 | 138 | let totalTranslatedText = ''; 139 | for (const chunk of chunks) { 140 | l('\n\n') 141 | l('chunk'); 142 | l(chunk); 143 | 144 | const translated = await translateText({ 145 | text: chunk, 146 | targetLanguageCode: 'en' 147 | }); 148 | 149 | l('translated'); 150 | l(translated) 151 | l('\n\n') 152 | 153 | totalTranslatedText = totalTranslatedText + translated + '\n'; 154 | } 155 | 156 | l('totalTranslatedText'); 157 | l(totalTranslatedText); 158 | 159 | l('how many lines'); 160 | l(howManyLines(totalTranslatedText)); 161 | l(howManyLines(textToTranslate)); 162 | l(howManyLines(recombined)); 163 | 164 | l('totalTranslatedText'); 165 | l(totalTranslatedText); 166 | l('textToTranslate'); 167 | l(textToTranslate); 168 | } 169 | 170 | // newMain() 171 | 172 | async function main () { 173 | try { 174 | const translated = await translateText({ 175 | text: textToTranslate, 176 | targetLanguageCode: 'en' 177 | }); 178 | 179 | // l('translated'); 180 | // l(translated); 181 | } catch (err) { 182 | l('err'); 183 | l(err); 184 | } 185 | } 186 | 187 | // main(); 188 | 189 | module.exports = splitAndTranslateText; 190 | -------------------------------------------------------------------------------- /translate/helpers.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const readline = require('readline'); 3 | 4 | l = console.log; 5 | 6 | 7 | function isTimestampLine (num) { 8 | return (num - 3) % 3 === 0; 9 | } 10 | 11 | function isTextLine (num) { 12 | return num !== 1 && ((num - 1) % 3 === 0); 13 | } 14 | 15 | 16 | const srtPath = '../examples/dnevnik.srt' 17 | 18 | let topLevelValue = 1; 19 | async function stripOutTextAndTimestamps (filePath, readableStream) { 20 | return new Promise(async (resolve, reject) => { 21 | let rl; 22 | if (readableStream) { 23 | rl = await readline.createInterface({ 24 | input: filePath 25 | }); 26 | } else { 27 | rl = readline.createInterface({ 28 | input: fs.createReadStream(filePath, 'utf8') 29 | }); 30 | } 31 | 32 | let strippedText = ''; 33 | let timestampsArray = []; 34 | 35 | let counter = 1; 36 | rl.on('line', (line) => { 37 | // l(counter) 38 | // l(line); 39 | const timestampLine = isTimestampLine(counter) 40 | const textLine = isTextLine(counter) 41 | // l('is timestamp line', timestampLine) 42 | // l('is text line', textLine) 43 | // l(`\n\n`) 44 | if (textLine) { 45 | strippedText = strippedText + `${line}\n` 46 | } 47 | if (timestampLine) { 48 | timestampsArray.push(line) 49 | } 50 | counter++ 51 | }); 52 | 53 | rl.on('close', async function () { 54 | l('\n\n') 55 | resolve({ 56 | strippedText, 57 | timestampsArray 58 | }) 59 | }) 60 | 61 | rl.on('error', function (err) { 62 | reject(err) 63 | }); 64 | 65 | }); 66 | 67 | } 68 | 69 | // async function main(){ 70 | // const { strippedText, timestampsArray } = await stripOutTextAndTimestamps(srtPath) 71 | // l(strippedText); 72 | // l(timestampsArray); 73 | // } 74 | 75 | // main(); 76 | 77 | const timestampArray = [ 78 | '00:00.000 --> 00:24.860', 79 | '00:24.860 --> 00:34.860', 80 | '00:34.860 --> 00:44.860', 81 | '00:44.860 --> 00:52.860', 82 | '00:52.860 --> 01:04.860' 83 | ]; 84 | 85 | const translatedText = 'Good day. I\'m Mirela Vasin, and this is the News of the Day.\n' + 86 | 'A Serb, a former member of the Kosovo Police, was arrested in spring. Serbs began to gather and set up barricades.\n' + 87 | 'Prime Minister Anna Brnabić appealed to the International Community not to turn its head away from the human rights of Serbs in Kosmet.\n' + 88 | 'Ukraine asks for additional weapons, Moscow warns of consequences.\n' + 89 | 'Today, two quarter-final matches are being played at the world football championship - Morocco-Portugal and England-France.' 90 | 91 | 92 | function reformatVtt (timestampArray, translatedText) { 93 | // l('timestampArray') 94 | // l(timestampArray); 95 | 96 | const splitText = translatedText.split('\n').slice(0, -1); 97 | l(splitText) 98 | 99 | let formattedVtt = 'WEBVTT\n'; 100 | 101 | for (const [index, value] of splitText.entries()) { 102 | formattedVtt = formattedVtt + `\n${timestampArray[index]}\n${value}\n` 103 | } 104 | 105 | return formattedVtt 106 | } 107 | 108 | module.exports = { 109 | stripOutTextAndTimestamps, 110 | reformatVtt 111 | } 112 | 113 | // async function main(){ 114 | // const { strippedText, timestampsArray } = await stripOutTextAndTimestamps(srtPath) 115 | // l(strippedText); 116 | // l(timestampsArray); 117 | // 118 | // const formattedVtt = reformatVtt(timestampsArray, translatedText) 119 | // l(formattedVtt) 120 | // } 121 | // 122 | // main(); 123 | -------------------------------------------------------------------------------- /translate/libreTranslateWrapper.js: -------------------------------------------------------------------------------- 1 | const fetch = require('node-fetch'); 2 | const l = console.log; 3 | 4 | // TODO: replace this with new instance 5 | const LTHost = process.env.LIBRETRANSLATE; 6 | 7 | // l('LTHost'); 8 | // l(LTHost) 9 | 10 | const endpoint = LTHost + '/translate'; 11 | 12 | // l('endpoint'); 13 | // l(endpoint) 14 | 15 | 16 | process.on('unhandledRejection', (reason, promise) => { 17 | l(reason); 18 | l(promise); 19 | }); 20 | 21 | async function hitLTBackend ({ text, sourceLanguage, targetLanguage }) { 22 | const res = await fetch(endpoint, { 23 | method: 'POST', 24 | body: JSON.stringify({ 25 | q: text, 26 | source: sourceLanguage, 27 | target: targetLanguage 28 | }), 29 | headers: { 'Content-Type': 'application/json' } 30 | }); 31 | 32 | return await res.json() 33 | } 34 | 35 | async function translateText ({ text, sourceLanguage, targetLanguage }) { 36 | const translatedResponse = await hitLTBackend({ text, sourceLanguage, targetLanguage }); 37 | // l(translatedResponse); 38 | 39 | const { translatedText, detectedLanguage } = translatedResponse; 40 | return translatedText; 41 | } 42 | 43 | // /** all languages should be as abbreviation **/ 44 | // 45 | // // translate from this language 46 | // const sourceLanguage = 'auto'; 47 | // 48 | // // into this language 49 | // const targetLanguage = 'es'; 50 | // 51 | // const text = 'This is the text I want to translate'; 52 | // 53 | // // translate({ text, sourceLanguage, targetLanguage }); 54 | // 55 | // async function main(){ 56 | // const translatedText = await translateText({ text, sourceLanguage, targetLanguage }); 57 | // l('translatedText'); 58 | // l(translatedText); 59 | // } 60 | // 61 | // // main(); 62 | 63 | module.exports = translateText; 64 | -------------------------------------------------------------------------------- /translate/translate-files-api.js: -------------------------------------------------------------------------------- 1 | const translateText = require('./libreTranslateWrapper'); 2 | const fs = require('fs-extra'); 3 | const projectConstants = require('../constants/constants'); 4 | const { languagesToTranscribe, translationLanguages } = projectConstants; 5 | 6 | // const languagesToTranscribe = {} 7 | 8 | let l = console.log; 9 | 10 | // l('languages to transcribe'); 11 | // l(languagesToTranscribe) 12 | // [ 'English', 'French', 'German', 'Spanish', 'Russian', 'Japanese' ] 13 | 14 | if (global.debug === 'false') { 15 | l = function () {} 16 | } 17 | 18 | function getCodeFromLanguageName (languageName) { 19 | return translationLanguages.find(function (filteredLanguage) { 20 | return languageName === filteredLanguage.name; 21 | }).code 22 | } 23 | 24 | /** for translation **/ 25 | async function createTranslatedFiles ({ 26 | directoryAndFileName, 27 | language, 28 | }) { 29 | const webVttData = await fs.readFile(`${directoryAndFileName}.vtt`, 'utf-8'); 30 | 31 | // copy original as copied 32 | await fs.copy(`${directoryAndFileName}.vtt`, `${directoryAndFileName}_${language}.vtt`) 33 | 34 | for (const languageToConvertTo of languagesToTranscribe) { 35 | l('languageToConvertTo'); 36 | l(languageToConvertTo); 37 | 38 | l('language'); 39 | l(language); 40 | 41 | try { 42 | // no need to translate just copy the file 43 | if (languageToConvertTo !== language) { 44 | // hit LibreTranslate backend 45 | l(`hitting libretranslate: ${language} -> ${languageToConvertTo}`); 46 | 47 | let translatedText = await translateText({ 48 | sourceLanguage: getCodeFromLanguageName(language), // before these were like 'EN', will full language work? 49 | targetLanguage: getCodeFromLanguageName(languageToConvertTo), 50 | text: webVttData, 51 | }) 52 | 53 | // do the regexp here 54 | translatedText.replace(/^(\d[\d:.]+\d) .*?( \d[\d:.]+\d)$/gm, '$1 -->$2'); 55 | 56 | translatedText = 'WEBVTT' + translatedText.slice(6); 57 | 58 | await fs.writeFile(`${directoryAndFileName}_${languageToConvertTo}.vtt`, translatedText, 'utf-8'); 59 | } 60 | } catch (err) { 61 | l(err); 62 | l('error in translation'); 63 | return err 64 | } 65 | } 66 | } 67 | 68 | module.exports = createTranslatedFiles; 69 | 70 | -------------------------------------------------------------------------------- /views/addTranslation/addTranslation.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | 5 | h2= title 6 | form(action=`/player/` + renderedFilename + `/add` method='post') 7 | input(type='text', name="language" placeholder="Language" value="English") 8 | br 9 | textarea#message(name='message' rows='40' cols='100' value="Hello World") 10 | br 11 | input(type='submit' value='Submit') 12 | 13 | script. 14 | $(document).ready(function() { 15 | 16 | }); 17 | // const originalVtt = `#{originalVtt}` 18 | // document.getElementById('message').innerHTML = originalVtt; 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /views/admin.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | style. 5 | p { 6 | margin: 2px auto; 7 | } 8 | 9 | 10 | div 11 | h1 Admin 12 | br 13 | 14 | h2 Job Processes (#{Object.keys(jobProcesses).length}) 15 | p global.jobProcesses 16 | br 17 | each val, key in jobProcesses 18 | p Process Number: #{key} 19 | br 20 | if val 21 | each val2, key2 in val 22 | p #{key2}: #{val2} 23 | br 24 | 25 | br 26 | h2 New Queue Items (#{newQueue && newQueue.length}) 27 | p global.newQueue 28 | br 29 | 30 | each item, index in newQueue 31 | p Place In Queue: #{index + 1} 32 | each val, key in item 33 | p #{key}: #{val} 34 | br 35 | br 36 | 37 | br 38 | h2 Transcription/Download Processes (#{transcriptions && transcriptions.length}) 39 | p global.transcriptions 40 | br 41 | each item in transcriptions 42 | each val, key in item 43 | p #{key}: #{val} 44 | br 45 | br 46 | 47 | h2 Websocket Connections (#{webSocketData && webSocketData.length}) 48 | p global.webSocketData 49 | br 50 | each item in webSocketData 51 | each val, key in item 52 | p #{key}: #{val} 53 | br 54 | 55 | 56 | 57 | -------------------------------------------------------------------------------- /views/error.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | h1= message 5 | h2= error.status 6 | pre #{error.stack} 7 | -------------------------------------------------------------------------------- /views/files.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | style. 5 | video { 6 | top: 0px; 7 | } 8 | 9 | 10 | div 11 | h1 Files 12 | 13 | table#myTable 14 | tbody 15 | tr 16 | th Filename 17 | th Language 18 | th Keep Media 19 | th Duration 20 | th Date 21 | 22 | 23 | each file in files 24 | tr 25 | 26 | td(data-sort-value=file.name) 27 | a(href='/player/' + file.name target="_blank") #{file.name} 28 | td(data-sort-value=file.processingData.language) #{file.processingData.language} 29 | td(data-sort-value=file.processingData.keepMedia) #{file.processingData.keepMedia} 30 | td(data-sort-value=file.processingData.uploadDurationInSeconds data-type="number") #{file.processingData.uploadDurationInSecondsHumanReadable} 31 | td(data-sort-value=file.timestamp data-type) #{file.formattedDate} 32 | 33 | 34 | script. 35 | l = console.log; 36 | 37 | $(document).ready(function () { 38 | console.log("ready!"); 39 | 40 | // probably better to do on plyr ready if available as event 41 | setTimeout(function(){ 42 | // const captionsIsPressed = $('button[data-plyr="captions"]')?.hasClass('plyr__control--pressed') 43 | 44 | }, 200) 45 | 46 | function convertToNumber(string) { 47 | const number = Number(string); 48 | if (isNaN(number)) { 49 | // If the string is not a valid number, return 0 50 | return 0; 51 | } else { 52 | // If the string is a valid number, return the number 53 | return number; 54 | } 55 | } 56 | 57 | var table = document.getElementById("myTable"); 58 | var currentSort = null; // Keep track of the current sort column 59 | 60 | table.addEventListener("click", function (event) { 61 | // Get the element that was clicked 62 | var element = event.target; 63 | 64 | // If the element is a table header, sort the table by the corresponding column 65 | if (element.tagName == "TH") { 66 | // Get the index of the column that was clicked 67 | var index = Array.prototype.indexOf.call(element.parentNode.children, element); 68 | 69 | // If the same column was clicked twice, reverse the sort order 70 | if (currentSort === index) { 71 | currentSort = -1 * currentSort; 72 | } else { 73 | currentSort = index; 74 | } 75 | 76 | // Sort the table by the values in the clicked column 77 | sortTable(table, index, currentSort); 78 | } 79 | }); 80 | 81 | function sortTable(table, column, sortOrder) { 82 | // Get the rows of the table 83 | var rows = table.rows; 84 | 85 | // Convert the rows to an array (we'll need to sort this array) 86 | var rowArray = Array.prototype.slice.call(rows, 1); 87 | 88 | // Sort the array of rows according to the values in the specified column 89 | rowArray.sort(function (a, b) { 90 | // Get the text content of the cells in the specified column 91 | var A = a.cells[column].getAttribute("data-sort-value"); 92 | var B = b.cells[column].getAttribute("data-sort-value"); 93 | 94 | // Check if the values are numeric or not 95 | if (isNaN(A) || isNaN(B)) { 96 | // Compare the values as strings 97 | if (sortOrder > 0) { 98 | if (A < B) return -1; 99 | if (A > B) return 1; 100 | return 0; 101 | } else { 102 | if (A > B) return -1; 103 | if (A < B) return 1; 104 | return 0; 105 | } 106 | } else { 107 | // Convert the values to numbers and compare them 108 | A = parseFloat(A); 109 | B = parseFloat(B); 110 | if (sortOrder > 0) { 111 | if (A < B) return -1; 112 | if (A > B) return 1; 113 | return 0; 114 | } else { 115 | if (A > B) return -1; 116 | if (A < B) return 1; 117 | return 0; 118 | } 119 | } 120 | }); 121 | 122 | // Remove the existing rows from the table 123 | while (rows.length > 1) { 124 | table.deleteRow(1); 125 | } 126 | 127 | // Add the sorted rows back to the table 128 | rowArray.forEach(function (row) { 129 | table.appendChild(row); 130 | }); 131 | } 132 | 133 | 134 | }); 135 | 136 | 137 | 138 | -------------------------------------------------------------------------------- /views/index/components/amounts-header.pug: -------------------------------------------------------------------------------- 1 | style. 2 | input[type=file], /* FF, IE7+, chrome (except button) */ 3 | input[type=file]::-webkit-file-upload-button { /* chromes and blink button */ 4 | cursor: pointer; 5 | } 6 | 7 | if isFreeSubtitles 8 | .firstHeader 9 | a(href='/' target='_blank' style="text-decoration: none;") 10 | h1 FreeSubtitles.AI 11 | .transcribedStats 12 | h3.amountsHeader #{siteStats.amountOfTranscriptions} transcriptions completed 13 | h3.amountsHeader #{siteStats.humanReadableTime} transcribed 14 | //.fileLimitsDiv 15 | // h3.amountsHeader File Limits: 16 | // h3.amountsHeader Size: #{uploadLimitInMB}MB 17 | // h3.amountsHeader Duration: 1 Hour 18 | -------------------------------------------------------------------------------- /views/index/components/social-buttons.pug: -------------------------------------------------------------------------------- 1 | .social 2 | a(href='https://github.com/mayeaux/generate-subtitles' target='_blank' rel='noreferrer' aria-label='GitHub Link') 3 | img#githubLogo(src='/images/inverted.png') 4 | a(href='https://discord.gg/vfXg7GHMpR' target='_blank' rel='noreferrer' aria-label='Discord Link') 5 | img#discordLogo(src='/images/discordLogo.png') 6 | a(href='https://t.me/freesubtitles_ai' target='_blank' rel='noreferrer' aria-label='Telegram Link') 7 | img#telegramLogo(src='/images/telegramLogo.webp') 8 | a(href='https://twitter.com/FreeSubtitlesAI' target='_blank' rel='noreferrer' aria-label='Twitter Link') 9 | img#twitterLogo(src='/images/twitterLogo.png') -------------------------------------------------------------------------------- /views/index/components/transcription-results.pug: -------------------------------------------------------------------------------- 1 | //DISPLAY DIFFERENT DATA 2 | .transcription-data 3 | p#progress 4 | .progress 5 | p#timeEstimator 6 | p#latestData 7 | p#processingData 8 | p#secondProcessingData 9 | p#finishedData 10 | 11 | // kind of duplicated, not sure it's needed 12 | a#startNewUpload.startNewUpload.btn(onclick='(function(){ window.open(window.location.href, \'_blank\').focus(); })();') Start Another Transcription 13 | 14 | .download-links 15 | a.btn.downloadLink#srtDownloadLink(href='#') 16 | 17 | a.btn.downloadLink#vttDownloadLink(href='#') 18 | 19 | a.btn.downloadLink#txtDownloadLink(href='#') 20 | 21 | a.btn#refreshButton(onclick='(function(){ window.open(window.location.href, \'_blank\').focus(); })();') Start New Transcription 22 | -------------------------------------------------------------------------------- /views/index/components/upload-form.pug: -------------------------------------------------------------------------------- 1 | style. 2 | 3 | .disabled-paste-button, .paste-button { 4 | display: inline-block; 5 | margin: 0; 6 | margin-left: 5px; 7 | cursor: pointer; 8 | color: #339233; 9 | } 10 | 11 | #downloadLinkLabel, .paste-button { 12 | /*pointer-events: none;*/ 13 | 14 | } 15 | 16 | #disable-download-link { 17 | cursor: pointer !important; 18 | } 19 | 20 | .paste-button { 21 | /*pointer-events: ;*/ 22 | } 23 | 24 | .file-turned-off { 25 | opacity: 0.5; 26 | pointer-events: none; 27 | } 28 | 29 | input::placeholder { 30 | color: #636262; 31 | } 32 | 33 | 34 | 35 | form.form#form 36 | p(style="margin:0;margin-bottom:6px;") File Limits: Video Or Audio, Max 300 MB, Max 1 Hour Duration 37 | .group 38 | label(for='file') Select (or drop) file 39 | input#file(type='file') 40 | if !ytdlp 41 | .group 42 | label#disable-download-link(for='downloadLink') Enter a link for automatic download 43 | p.disabled-paste-button PASTE 44 | input(disabled #disable-download-link style="cursor:pointer;")(type='text' value="Paid Feature, More Info Coming Soon") 45 | if ytdlp 46 | label(for='downloadLink') Enter a link for automatic download 47 | p.paste-button PASTE 48 | input#downloadLink(type='text' placeholder="Enter a link to a video here") 49 | .group 50 | label(for='languageSelect')#languageLabel Language (#{languagesArray.length} Options) 51 | .wrapper 52 | button#languageSelect.select-btn(type='button') 53 | span#language.select-value(data-value='auto-detect' name='language') Auto-Detect 54 | i.fa-solid.fa-angle-down 55 | .content 56 | .search 57 | i.fa-solid.fa-magnifying-glass 58 | input(spellcheck='false' type='search' placeholder='Filter...' tabindex='0') 59 | ul.options.languages 60 | each language in languagesArray 61 | li.option(data-value=language.value tabindex='0')= language.name 62 | .group 63 | label(for='modelSelect')#modelLabel Model (#{modelsArray.length} Options) 64 | .wrapper 65 | button#modelSelect.select-btn(type='button') 66 | span#model.select-value(name='model')= modelsArray[0].name 67 | i.fa-solid.fa-angle-down 68 | .content 69 | .search 70 | i.fa-solid.fa-magnifying-glass 71 | input(spellcheck='false' type='search' placeholder='Filter...') 72 | ul.options.models 73 | each model in modelsArray 74 | li.option(data-value= model.value tabindex='0')= model.name 75 | .group.translateHolder 76 | label#translateLabel(for='translate') Translate to different languages 77 | input#translate(type='checkbox' name='translate') 78 | button#upload-button.submit-btn(type='submit') Upload 79 | 80 | 81 | script. 82 | let l = console.log; 83 | 84 | let pasteButtonTurnedOff = false; 85 | 86 | $(document).on('click', function (e) { 87 | 88 | // workaround since element is diabled 89 | if (e.target.attributes.getNamedItem('#disable-download-link')) { 90 | Swal.fire({ 91 | title: `Restricted access feature`, 92 | text: 'This feature is too powerful for public use, reach out on one of the social media for access', 93 | icon: 'info', 94 | confirmButtonText: 'Back to Upload' 95 | }) 96 | } 97 | }); 98 | 99 | // TODO: add this download link 100 | function isValidLink(input) { 101 | const pattern = /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/; 102 | return pattern.test(input); 103 | } 104 | 105 | $('.paste-button').click(function(event) { 106 | l('clicked!'); 107 | l('pasteButtonTurnedOff', pasteButtonTurnedOff); 108 | 109 | if(pasteButtonTurnedOff) { 110 | return; 111 | } 112 | 113 | navigator.clipboard.readText().then(clipText => { 114 | $('#downloadLink').val(clipText); 115 | updateCss() 116 | }); 117 | }); 118 | 119 | function updateCss(){ 120 | const currentInputValue = $('#downloadLink').val(); 121 | 122 | l('currentInputValue', currentInputValue); 123 | 124 | // if downloadlink is empty 125 | const inputIsEmpty = currentInputValue === ''; 126 | 127 | // re-enable file input 128 | if (inputIsEmpty) { 129 | $('#file').removeClass('file-turned-off'); 130 | } else { 131 | // disable file input 132 | l('turning off paste button') 133 | // re-enable file input and paste button 134 | $('#file').addClass('file-turned-off'); 135 | } 136 | } 137 | 138 | $('#downloadLink').keyup(() => { 139 | updateCss() 140 | }); 141 | 142 | $('#downloadLink').change(() => { 143 | updateCss() 144 | }); 145 | 146 | $('#file').change(function () { 147 | // there was a file present 148 | if ($('#file')[0]?.files?.length > 0) { 149 | $('#downloadLink').addClass('file-turned-off'); 150 | $('.paste-button').addClass('file-turned-off'); 151 | 152 | $('#downloadLink').attr('placeholder', 'You have selected a file to upload'); 153 | pasteButtonTurnedOff = true; 154 | 155 | l('file added') 156 | // a file that was there was removed 157 | } else { 158 | $('#downloadLink').removeClass('file-turned-off'); 159 | $('.paste-button').removeClass('file-turned-off'); 160 | 161 | $('#downloadLink').attr('placeholder', 'Enter a link to a video here'); 162 | 163 | pasteButtonTurnedOff = false; 164 | 165 | 166 | l('file removed') 167 | } 168 | }); 169 | 170 | -------------------------------------------------------------------------------- /views/index/index.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | script(src='/javascripts/circle-progress.min.js') 5 | 6 | include ../styles/styles-global 7 | include styles/styles-social 8 | include styles/styles-amounts-header 9 | include styles/styles-form 10 | include styles/styles-transcription-results 11 | 12 | include components/social-buttons 13 | 14 | .container 15 | 16 | include components/amounts-header 17 | 18 | h1#header File Upload 19 | include components/upload-form 20 | 21 | include components/transcription-results 22 | 23 | include js/js-index -------------------------------------------------------------------------------- /views/index/js/controllers/error-handling.pug: -------------------------------------------------------------------------------- 1 | script. 2 | const errorsMap = { 3 | 'no-file': () => 4 | Swal.fire({ 5 | title: `You didn't pass a file`, 6 | text: 'Please select a file to upload.', 7 | icon: 'error', 8 | confirmButtonText: 'Cool' 9 | }), 10 | 'too-many-outstanding': () => 11 | Swal.fire({ 12 | title: `Slow down a bit`, 13 | text: 'You already have 3 outstanding jobs which is the maximum for now, please wait for one of them to finish before you upload another file', 14 | icon: 'error', 15 | confirmButtonText: 'Cool' 16 | }), 17 | 'too-large-file': (size, sizeLimit) => 18 | Swal.fire({ 19 | title: `Too large file`, 20 | text: `Your file size of ${size}MB is too large. The limit is ${sizeLimit}MB.`, 21 | icon: 'error', 22 | confirmButtonText: 'Cool' 23 | }), 24 | 'too-long-video': (duration, durationLimit) => 25 | Swal.fire({ 26 | title: `Too long video`, 27 | text: `Your video duration of ${duration / 60} minutes is too long. To share time more evenly 1h+ uploads are not allowed currently.`, 28 | icon: 'error', 29 | confirmButtonText: 'Cool' 30 | }), 31 | 'transcription-error': text => 32 | Swal.fire({ 33 | title: 'Transcription Failed', 34 | text, 35 | icon: 'error', 36 | showCancelButton: true, 37 | confirmButtonText: 'Try Again', 38 | }).then(result => result.isConfirmed && window.location.reload()), 39 | 'interrupted-connection': () => 40 | Swal.fire({ 41 | title: 'Websocket Disconnected', 42 | text: 'Your websocket connection was disconnected, please refresh to start a new upload', 43 | icon: 'error', 44 | showCancelButton: true, 45 | confirmButtonText: 'Refresh', 46 | }).then(result => result.isConfirmed && window.location.reload()), 47 | } 48 | -------------------------------------------------------------------------------- /views/index/js/controllers/file-handling.pug: -------------------------------------------------------------------------------- 1 | script. 2 | const getFirstFile = evt => { 3 | let list = new DataTransfer(); 4 | let firstPassedFile = evt.dataTransfer.files[0]; 5 | list.items.add(firstPassedFile); 6 | return list; 7 | } 8 | 9 | const getVideoDuration = file => 10 | new Promise((resolve, reject) => { 11 | const reader = new FileReader(); 12 | l({file}); 13 | reader.onload = evt => { 14 | l({evt}); 15 | l('loading'); 16 | l({reader}); 17 | const media = new Audio(reader.result); 18 | l(media); 19 | media.onerror = err => { 20 | ce({err}); 21 | reject(err); 22 | } 23 | media.onloadedmetadata = () => resolve(media.duration); 24 | }; 25 | l('reading') 26 | reader.readAsDataURL(file); 27 | reader.onerror = function(error){ 28 | ce({error}) 29 | reject(error); 30 | } 31 | }); -------------------------------------------------------------------------------- /views/index/js/controllers/selection-dropdowns.pug: -------------------------------------------------------------------------------- 1 | script. 2 | const updateSelection = option => { 3 | const selectedValueEl = option.closest('.wrapper').querySelector('.select-value'); 4 | const selectedOption = option.closest('.wrapper').querySelector('.selected'); 5 | selectedOption.classList.remove('selected'); 6 | option.classList.add('selected'); 7 | selectedValueEl.textContent = option.textContent; 8 | selectedValueEl.dataset.value = option.dataset.value; 9 | } 10 | 11 | const handleSelectionButtonClick = evt => { 12 | const selectWrapper = evt.target.closest('.wrapper'); 13 | const input = selectWrapper.querySelector('.search input'); 14 | selectWrapper.classList.toggle('active'); 15 | selectWrapper.querySelector('.selected').scrollIntoView({block: 'center'}); // scroll to selected option // doesn't work for some reason // TODO 16 | input.focus(); 17 | } 18 | 19 | const handleOptionClick = evt => { 20 | const option = evt.target; 21 | const selectWrapper = option.closest('.wrapper'); 22 | updateSelection(option); 23 | selectWrapper.classList.remove('active'); 24 | selectWrapper.querySelector('.select-btn').focus(); 25 | } 26 | 27 | const handleSelectionShortcuts = evt => { 28 | const key = evt.key; 29 | ['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(key) && evt.preventDefault(); 30 | 31 | const selectionContent = evt.target.closest('form .content'); 32 | const focusedOption = document.activeElement.closest('.option'); 33 | const focusedInput = document.activeElement.closest('input[type="search"]'); 34 | 35 | const keysMap = [ 36 | {command: /ArrowDown/, action: () => { 37 | //- focusedInput ? getNextVisibleSibling(selectionContent.querySelector('.selected'))?.focus() 38 | //- : 39 | // if there is a focused option, focus the next one, else focus the first one 40 | (getNextVisibleSibling(focusedOption) ?? 41 | getFirstVisibleSibling(selectionContent.querySelector('.options'))) 42 | .focus(); 43 | }}, 44 | 45 | {command: /ArrowUp/, action: () => { 46 | (getPreviousVisibleSibling(focusedOption) ?? 47 | getLastVisibleSibling(selectionContent.querySelector('.options'))) 48 | .focus(); 49 | }}, 50 | 51 | {command: /Enter/, action: () => focusedOption?.click()}, 52 | 53 | {command: /Escape/, action: () => selectionContent.closest('.wrapper').classList.remove('active')}, 54 | 55 | {command: /[a-z]/i, action: () => { 56 | selectionContent.querySelector(`input[type='search']`).focus(); 57 | }}, 58 | ]; 59 | 60 | keysMap.find(({command}) => command.test(key))?.action(); 61 | } 62 | 63 | const handleSelectionInput = evt => { 64 | const searchInput = evt.target; 65 | const options = Array.from(searchInput.closest('.content').querySelectorAll('.option')); 66 | const selectButton = searchInput.closest('.wrapper').querySelector('.select-btn'); 67 | selectButton.id === 'languageSelect' && options.shift(); 68 | const filteredOptions = filterOptions(options, searchInput.value); 69 | 70 | options.forEach(option => { 71 | filteredOptions.includes(option) 72 | ? option.classList.remove('hidden') 73 | : option.classList.add('hidden'); 74 | }); 75 | } -------------------------------------------------------------------------------- /views/index/js/js-util.pug: -------------------------------------------------------------------------------- 1 | script. 2 | const unescapeHTML = escapedHTML => escapedHTML 3 | .replace(/</g, '<') 4 | .replace(/>/g, '>') 5 | .replace(/&/g, '&'); 6 | 7 | const filterOptions = (options, q) => { 8 | const regex = new RegExp(`^${q}`, 'i'); 9 | return options.filter(opt => !!opt.dataset.value.match(regex)); 10 | } 11 | 12 | //- get next, previous, first, and last visible siblings with recursion 13 | 14 | const getNextVisibleSibling = el => el?.nextSibling?.classList.contains('hidden') 15 | ? getNextVisibleSibling(el.nextSibling) : el?.nextSibling; 16 | 17 | const getPreviousVisibleSibling = el => el?.previousSibling?.classList.contains('hidden') 18 | ? getPreviousVisibleSibling(el.previousSibling) : el?.previousSibling; 19 | 20 | const getFirstVisibleSibling = el => el?.firstChild?.classList.contains('hidden') 21 | ? getNextVisibleSibling(el.firstChild) : el?.firstChild; 22 | 23 | const getLastVisibleSibling = el => el?.lastChild?.classList.contains('hidden') 24 | ? getPreviousVisibleSibling(el.lastChild) : el?.lastChild; 25 | 26 | -------------------------------------------------------------------------------- /views/index/styles/styles-amounts-header.pug: -------------------------------------------------------------------------------- 1 | style. 2 | .amountsHeader { 3 | text-align: center; 4 | color: white; 5 | font-size: 21px; 6 | } 7 | 8 | .amountsHeader:nth-of-type(1) { 9 | margin-bottom: -1px; 10 | } 11 | 12 | .amountsHeader:nth-of-type(2) { 13 | margin-top: 11px; 14 | } 15 | 16 | .fileLimitsDiv { 17 | padding-bottom: 3px; 18 | } 19 | 20 | .firstHeader { 21 | margin-top: -75px; 22 | margin-bottom: 59px; 23 | } 24 | -------------------------------------------------------------------------------- /views/index/styles/styles-form.pug: -------------------------------------------------------------------------------- 1 | style. 2 | form { 3 | padding: 30px; 4 | display: flex; 5 | flex-direction: column; 6 | gap: 1em; 7 | background-color: white; 8 | font-size: 0.9rem; 9 | } 10 | 11 | form :where(input:not([type='checkbox']), .select-btn) { 12 | padding: 0.5em 1em; 13 | width: 100%; 14 | border: 1px solid #ccc; 15 | font-size: inherit; 16 | } 17 | 18 | form .group { 19 | display: flex; 20 | flex-direction: column; 21 | gap: 0.5em; 22 | } 23 | 24 | form .group .wrapper { 25 | position: relative; 26 | width: 100%; 27 | } 28 | 29 | .select-btn, li{ 30 | width: 100%; 31 | padding: 10px 16px; 32 | display: flex; 33 | align-items: center; 34 | cursor: pointer; 35 | } 36 | 37 | .select-btn { 38 | background: transparent; 39 | justify-content: space-between; 40 | } 41 | 42 | .select-btn i { 43 | font-size: 1.5rem; 44 | transition: transform 250ms ease-in-out; 45 | } 46 | 47 | .wrapper.active .select-btn i { 48 | transform: rotate(-180deg); 49 | } 50 | 51 | .content { 52 | position: absolute; 53 | top: 3.5rem; 54 | z-index: 1; 55 | width: 100%; 56 | display: none; 57 | flex-direction: column; 58 | visibility: hidden; 59 | opacity: 0; 60 | max-height: 0; 61 | padding: 16px; 62 | padding-bottom: 0; 63 | background: #fff; 64 | box-shadow: 0 10px 25px rgba(0,0,0,0.1); 65 | transition: max-height 250ms ease-in-out; 66 | } 67 | 68 | .wrapper.active .content{ 69 | display: flex; 70 | visibility: visible; 71 | opacity: 1; 72 | max-height: 350px; 73 | } 74 | 75 | .content .search { 76 | position: relative; 77 | display: flex; 78 | align-items: center; 79 | height: 2.5rem; 80 | gap: 0.5em; 81 | } 82 | 83 | .search i { 84 | color: #999; 85 | font-size: 1.25rem; 86 | pointer-events: none; 87 | } 88 | 89 | .search input { 90 | flex-grow: 1; 91 | outline: none; 92 | font-size: 1rem; 93 | padding: 0.5em 0.75em; 94 | border: 1px solid #B3B3B3; 95 | } 96 | 97 | .search input:focus{ 98 | border: 2px solid #4285f4; 99 | } 100 | 101 | .search input::placeholder{ 102 | color: #bfbfbf; 103 | } 104 | 105 | .search input::-webkit-search-cancel-button { 106 | cursor: pointer; 107 | font-size: 1.25em; 108 | } 109 | 110 | .content .options { 111 | display: flex; 112 | flex-direction: column; 113 | gap: 0.5em; 114 | margin-top: 10px; 115 | overflow-y: auto; 116 | padding-inline: 0.5em; 117 | background-color: #fff; 118 | } 119 | 120 | .options::-webkit-scrollbar{ 121 | width: 7px; 122 | } 123 | 124 | .options::-webkit-scrollbar-track{ 125 | background: #f1f1f1; 126 | border-radius: 1rem; 127 | } 128 | 129 | .options::-webkit-scrollbar-thumb{ 130 | background: #ccc; 131 | border-radius: 1rem; 132 | } 133 | 134 | .options::-webkit-scrollbar-thumb:hover { 135 | background: #b3b3b3; 136 | } 137 | 138 | .options li { 139 | margin: 0; 140 | font-size: 1rem; 141 | } 142 | 143 | .options li:where(:hover, :focus, .selected) { 144 | border-radius: 5px; 145 | background: hsl(0deg 0% 90%); 146 | } 147 | 148 | .options li:focus { 149 | outline: 0; 150 | box-shadow: 0 0 3px #4285f4; 151 | } 152 | 153 | #languageLabel, #modelLabel, #translateLabel, #translate { 154 | cursor: pointer; 155 | } 156 | 157 | #translate { 158 | position: relative; 159 | height: 1.1rem; 160 | width: 1.1rem; 161 | opacity: 0.8; 162 | margin-left: 2px; 163 | } 164 | 165 | 166 | .group.translateHolder { 167 | flex-direction: row; 168 | gap: 0.5em; 169 | align-items: center; 170 | } 171 | 172 | .submit-btn { 173 | width: 100%; 174 | border: none; 175 | background: rgb(60, 57, 57); 176 | font-size: 18px; 177 | color: white; 178 | border-radius: 3px; 179 | padding: 20px; 180 | text-align: center; 181 | cursor: pointer; 182 | } 183 | -------------------------------------------------------------------------------- /views/index/styles/styles-social.pug: -------------------------------------------------------------------------------- 1 | style. 2 | 3 | .social { 4 | width: 130px; 5 | position: absolute; 6 | right: 30px; 7 | display: flex; 8 | flex-direction: column; 9 | gap: 0.6em; 10 | } 11 | 12 | .social a { 13 | padding: 0.5em 1em; 14 | transition: all 200ms ease-in-out; 15 | } 16 | 17 | .social a:where(:hover, :focus) { 18 | transform: scale(1.1); 19 | } 20 | 21 | .social img { 22 | width: 100%; 23 | } 24 | 25 | #twitterLogo { 26 | margin-top: -14px; 27 | } -------------------------------------------------------------------------------- /views/index/styles/styles-transcription-results.pug: -------------------------------------------------------------------------------- 1 | style. 2 | 3 | .transcription-data { 4 | text-align: center; 5 | display: flex; 6 | flex-direction: column; 7 | gap: 0.75em; 8 | font-size: 1.25rem; 9 | color: #fff; 10 | } 11 | 12 | .downloadLink, #refreshButton, #startNewUpload { 13 | color: white; 14 | font-size: 1.05rem; 15 | text-align: center; 16 | } 17 | 18 | #timeEstimator { 19 | font-size: 1.25rem; 20 | text-align: center; 21 | } 22 | 23 | #finishedData { 24 | text-align: left; 25 | font-size: 1.125rem; 26 | color: white; 27 | margin-top: -12px; 28 | margin-left: -44px; 29 | margin-bottom: 7px; 30 | } 31 | 32 | #latestData, #processingData, #secondProcessingData, #refreshButton, #startNewUpload, .downloadLink { 33 | display: none; 34 | } 35 | 36 | #refreshButton, #startNewUpload { 37 | text-decoration: underline; 38 | } 39 | 40 | .startNewUpload { 41 | --color: #fff; 42 | color: var(--color); 43 | margin-block: 0.5em; 44 | margin-top: -10px; 45 | } 46 | 47 | #latestData, #processingData, #secondProcessingData, #timeEstimator, #finishedData { 48 | white-space: pre; 49 | } 50 | 51 | .downloadLink { 52 | color: white; 53 | font-size: 1.25rem; 54 | } 55 | 56 | .circle-progress-value { 57 | stroke-width: 50px; 58 | stroke: hsl(160, 63%, 55%); 59 | } 60 | 61 | .circle-progress-circle { 62 | stroke-width: 50px; 63 | stroke: #999; 64 | } 65 | 66 | .circle-progress-text { 67 | fill: white; 68 | } 69 | 70 | .progress svg { 71 | height: 202px; 72 | width: 202px; 73 | } 74 | 75 | .circle-progress-value { 76 | stroke-width: 50px; 77 | stroke: white; 78 | } 79 | 80 | .circle-progress-text { 81 | fill: black; 82 | font-size: 1.1rem; 83 | } 84 | 85 | .circle-progress-circle { 86 | stroke-width: 50px; 87 | stroke: #bdbdbd; 88 | } 89 | 90 | .progress { 91 | display: none; 92 | margin: 0 auto; 93 | text-align: center; 94 | } 95 | 96 | #progress, #latestData { 97 | font-size: 1.6rem; 98 | } 99 | 100 | #latestData { 101 | margin: 0 auto; 102 | } 103 | 104 | #processingData { 105 | margin: 14px auto; 106 | line-height: 17px; 107 | } 108 | 109 | #secondProcessingData { 110 | line-height: 17px; 111 | } 112 | -------------------------------------------------------------------------------- /views/layout.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html 3 | head 4 | title= title 5 | link(rel='stylesheet', href='/stylesheets/style.css') 6 | link(rel='icon' href='/favicon.ico?v=1.1') 7 | link(rel='apple-touch-icon' href='/images/favicon.ico') 8 | script(src='//cdn.jsdelivr.net/npm/sweetalert2@11') 9 | script(src='https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.1/jquery.min.js' integrity='sha512-aVKKRRi/Q/YV+4mjoKBsE4x3H+BkegoM/em46NNlCqNTmUYADjBbeNefNxYV7giUp0VxICtqdrbqU7iVaeZNXA==' crossorigin='anonymous' referrerpolicy='no-referrer') 10 | script(src='https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js' integrity='sha512-WFN04846sdKMIP5LKNphMaWzU7YpMyCU245etK3g/2ARYbPK9Ub18eG+ljU96qKRCWh+quCY7yefSmlkQw1ANQ==' crossorigin='anonymous' referrerpolicy='no-referrer') 11 | script(src='https://code.jquery.com/ui/1.13.2/jquery-ui.min.js' integrity='sha256-lSjKY0/srUM9BE3dPm+c4fBo1dky2v27Gdjm2uoZaL0=' crossorigin='anonymous') 12 | script(src='https://cdnjs.cloudflare.com/ajax/libs/balance-text/3.3.1/balancetext.min.js' integrity='sha512-LYmOcKYUkf2fYuZ4qCuLjIsJHAV1IbRFng30BKotYho3D8LG5R9Ehl4N3AMxQUJ3ANkWAJ4UVe62t36h4RId2g==' crossorigin='anonymous' referrerpolicy='no-referrer') 13 | //- Font Awesome icons are used in the custom dropdowns 14 | script(src='https://kit.fontawesome.com/109728983b.js' crossorigin='anonymous') 15 | body 16 | block content 17 | -------------------------------------------------------------------------------- /views/player/js/captionsDisplay.pug: -------------------------------------------------------------------------------- 1 | script. 2 | function setStoredLineHeightAndFontSizeIfExists() { 3 | const storedLineHeight = localStorage.getItem('storedLineHeight'); 4 | if (storedLineHeight) { 5 | subtitlesLineHeight = storedLineHeight; 6 | $('.plyr__caption').css('line-height', storedLineHeight); 7 | } else { 8 | const currentLineHeight = $('.plyr__caption').css('line-height'); 9 | localStorage.setItem('storedLineHeight', currentLineHeight); 10 | } 11 | 12 | const storedFontSize = localStorage.getItem('storedFontSize'); 13 | if (storedFontSize) { 14 | // l('found font size'); 15 | // l(storedFontSize) 16 | subtitlesFontSize = storedFontSize; 17 | 18 | $('.plyr__caption').css('font-size', storedFontSize); 19 | } else { 20 | const currentFontSize = $('.plyr__caption').css('font-size'); 21 | localStorage.setItem('storedFontSize', currentFontSize); 22 | } 23 | } 24 | 25 | function adjustFontSize(direction) { 26 | 27 | const currentLineHeight = $('.plyr__caption').css('line-height'); 28 | const currentLineHeight1 = currentLineHeight.replace('px', ''); 29 | const newLineHeight = parseInt(currentLineHeight1) + (direction === 'increase' ? 1 : -1); 30 | 31 | const currentFontSize = $('.plyr__caption').css('font-size'); 32 | const currentFontSize1 = currentFontSize.replace('px', ''); 33 | const newFontSize = parseInt(currentFontSize1) + (direction === 'increase' ? 1 : -1); 34 | 35 | l('subtitlesFontSize', subtitlesFontSize) 36 | l('subtitlesLineHeight', subtitlesLineHeight) 37 | 38 | const newLineHeightWithPx = `${newLineHeight}px`; 39 | const newFontSizeWithPx = `${newFontSize}px`; 40 | 41 | $('.plyr__caption').css('line-height', newLineHeightWithPx); 42 | $('.plyr__caption').css('font-size', newFontSizeWithPx); 43 | 44 | localStorage.setItem('storedLineHeight', newLineHeightWithPx); 45 | localStorage.setItem('storedFontSize', newFontSizeWithPx); 46 | 47 | subtitlesFontSize = newFontSizeWithPx; 48 | subtitlesLineHeight = newLineHeightWithPx; 49 | 50 | } 51 | 52 | function adjustLineHeight(direction) { 53 | const currentLineHeight = $('.plyr__caption').css('line-height'); 54 | const currentLineHeight1 = currentLineHeight.replace('px', ''); 55 | const newLineHeight = parseInt(currentLineHeight1) + (direction === 'increase' ? 1 : -1); 56 | 57 | 58 | const newLineHeightWithPx = `${newLineHeight}px`; 59 | 60 | // l('subtitlesLineHeight', subtitlesLineHeight) 61 | 62 | $('.plyr__caption').css('line-height', newLineHeightWithPx); 63 | 64 | localStorage.setItem('storedLineHeight', newLineHeightWithPx); 65 | 66 | subtitlesLineHeight = newLineHeightWithPx; 67 | } 68 | -------------------------------------------------------------------------------- /views/player/js/secondCaptions.pug: -------------------------------------------------------------------------------- 1 | script. 2 | function findIndexNumber(language, text) { 3 | const video = $('video')[0] 4 | const textTracks = video.textTracks; 5 | 6 | let foundIndex; 7 | let index = 0; 8 | for (const track of textTracks) { 9 | const trackLanguage = track.label 10 | if (language === trackLanguage) { 11 | for (const cue of track.cues) { 12 | if (cue.text === text) { 13 | foundIndex = index 14 | } 15 | index++ 16 | } 17 | } 18 | } 19 | 20 | return foundIndex 21 | } 22 | 23 | function loadTextTracks() { 24 | const video = $('video')[0] 25 | const textTracks = video.textTracks; 26 | for (const track of textTracks) { 27 | track.mode = 'hidden'; 28 | } 29 | } 30 | loadTextTracks() 31 | 32 | function findTextFromIndexNumber(language, index) { 33 | const video = $('video')[0] 34 | const textTracks = video.textTracks; 35 | 36 | let text; 37 | // l('text tracks'); 38 | // l(textTracks); 39 | 40 | for (const track of textTracks) { 41 | const trackLanguage = track.label 42 | if (language === trackLanguage) { 43 | text = track.cues[index]?.text 44 | return text 45 | } 46 | } 47 | return false; 48 | } 49 | 50 | function getCurrentLanguageAndTrack() { 51 | const video = $('video')[0] 52 | const textTracks = video.textTracks; 53 | const trackNumber = player.currentTrack; 54 | const currentTrack = textTracks[trackNumber] 55 | 56 | const language = currentTrack.label; 57 | const text = currentTrack.activeCues?.[0]?.text; 58 | 59 | const indexNumber = findIndexNumber(language, text); 60 | 61 | return { 62 | language, 63 | text, 64 | indexNumber 65 | } 66 | } 67 | 68 | function getSecondCaptionsDefaults() { 69 | let secendCaptionsDefaults = localStorage.getItem('secondCaptionsDefaults') 70 | if (!secendCaptionsDefaults) { 71 | localStorage.setItem('secondCaptionsDefaults', JSON.stringify({})) 72 | return {} 73 | } else { 74 | secendCaptionsDefaults = JSON.parse(secendCaptionsDefaults) 75 | return secendCaptionsDefaults 76 | } 77 | } 78 | 79 | function setSecondCaptionsDefaults(secondCaptionsLanguage) { 80 | const secondCaptionsDefaults = getSecondCaptionsDefaults() 81 | secondCaptionsDefaults[language] = secondCaptionsLanguage; 82 | localStorage.setItem('secondCaptionsDefaults', JSON.stringify(secondCaptionsDefaults)) 83 | } 84 | 85 | function buildMenuItemString(language, index) { 86 | return ``; 89 | } 90 | 91 | const disabledMenuItem = ` 92 | `; 95 | 96 | function createSecondCaptionsSetting() { 97 | const menuText = `${disabledMenuItem} 98 | ${allLanguages.map((language, index) => { 99 | return buildMenuItemString(language, index) 100 | }).join('')}`; 101 | 102 | // show second caption option in popup 103 | $('.plyr__menu__container > div > div > div > button')[1].removeAttribute('hidden') 104 | 105 | // write Second Captions name with Disabled as default on popup 106 | $('.plyr__menu__container > div > div > div > button').eq(1).html('Second CaptionsDisabled'); 107 | 108 | // add menu items for when you click the button 109 | $("div[id*='secondCaptions'] div").html(menuText) 110 | 111 | // set default chosen value as Disabled 112 | $("div[id*='secondCaptions'] button span").eq(0).text('Disabled') 113 | 114 | // handle when language value is clicked 115 | $("div[id*='secondCaptions'] div button").click(function () { 116 | // mark all languages as not checked 117 | $("div[id*='secondCaptions'] div button").attr("aria-checked", "false"); 118 | 119 | // get language of clicked value 120 | let languageValue = $(this).children().text(); 121 | 122 | // remove last two characters from string (couldn't get only French instead of FrenchFR) 123 | if (/[A-Z]{2}$/.test(languageValue)) { 124 | languageValue = languageValue.slice(0, -2); 125 | } 126 | 127 | setSecondCaptionsDefaults(languageValue) 128 | 129 | // get current vtt text 130 | const currentLang = getCurrentLanguageAndTrack(); 131 | 132 | const originalLanguage = $('div[role="menu"]').eq(0).children(1).children(1).eq(0).children(1).text(); 133 | 134 | const originalText = findTextFromIndexNumber(originalLanguage, currentLang.indexNumber); 135 | 136 | const translationText = findTextFromIndexNumber(languageValue, currentLang.indexNumber); 137 | 138 | // const originalText = $("span.plyr__caption").text(); 139 | 140 | // mark language as activated 141 | $(this).attr("aria-checked", "true"); 142 | 143 | // update text at the top of the menu options 144 | $("div[id*='secondCaptions'] button span").eq(0).text(languageValue) 145 | 146 | // immediately add text if they didn't select Disabled 147 | if (languageValue !== 'Disabled') { 148 | $("span.plyr__caption").text(`${originalText}\n${translationText}`); 149 | } 150 | 151 | // update text on secondCaptions option 152 | $('.plyr__menu__container > div > div > div > button').eq(1) 153 | .find('span.plyr__menu__value') 154 | .text(languageValue) 155 | 156 | // click back button (mirrors plyr functionality) 157 | $("div[id*='secondCaptions'] button span").eq(0).click() 158 | }) 159 | } 160 | -------------------------------------------------------------------------------- /views/player/js/videoProgress.pug: -------------------------------------------------------------------------------- 1 | script. 2 | function getStoredVideoProgress() { 3 | const storedVideoProgress = localStorage.getItem('storedVideoProgress'); 4 | if (storedVideoProgress) { 5 | return JSON.parse(storedVideoProgress); 6 | } 7 | return {}; 8 | } 9 | 10 | function setStoredVideoProgress(ended) { 11 | const storedVideoProgress = getStoredVideoProgress(); 12 | if (ended) { 13 | storedVideoProgress[filename] = 0; 14 | } else { 15 | storedVideoProgress[filename] = player.currentTime; 16 | } 17 | localStorage.setItem('storedVideoProgress', JSON.stringify(storedVideoProgress)); 18 | } 19 | -------------------------------------------------------------------------------- /views/player/player.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | 5 | include ../styles/styles-global 6 | include styles-player 7 | 8 | main 9 | section.video-container 10 | video#player(playsinline controls='' autoplay='true' name='media' preload="auto") 11 | // file extension includes the . 12 | source(src=`${filePathWithoutExtension}${processingData.fileExtension}`) 13 | // TODO: replace with loop (has to fix on the backend) 14 | if languagesToLoop.length > 0 15 | each loopLanguage in languagesToLoop 16 | track(kind='captions' label=loopLanguage.name srclang=loopLanguage.languageCode src=`${filePathWithoutExtension}_${loopLanguage.name}.vtt` default='') 17 | track(kind='captions' label=processingData.language srclang=processingData.languageCode src=`${filePathWithoutExtension}_${processingData.language}.vtt` default=true) 18 | else 19 | track(kind='captions' label=processingData.language srclang=processingData.languageCode src=`${filePathWithoutExtension}.vtt` default=true) 20 | section.bottomSection 21 | .buttons 22 | button.btn#increaseSize Subtitle Text Size + 23 | button.btn#decreaseSize Subtitle Text Size - 24 | button.btn#increaseLineHeight Subtitle Spacing + 25 | button.btn#decreaseLineHeight Subtitle Spacing - 26 | button.btn#showHideControls Hide Text 27 | .downloadLinks 28 | a.btn.startAnotherTranscription(href='/') Start Another Transcription 29 | 30 | // ORIGINAL LANGUAGE FILES 31 | if processingData.translatedLanguages.length > 1 32 | span Original Language: 33 | .srtLinks.links 34 | span SRT (#{processingData.language}) 35 | a(download href=`${filePathWithoutExtension}.srt`) Download 36 | .vttLinks.links 37 | span VTT (#{processingData.language}) 38 | a(href=`${filePathWithoutExtension}.vtt`) View 39 | a(download href=`${filePathWithoutExtension}.vtt`) Download 40 | .txtLinks.links 41 | span TXT (#{processingData.language}) 42 | a(href=`${filePathWithoutExtension}.txt`) View 43 | a(download href=`${filePathWithoutExtension}.txt`) Download 44 | 45 | // TRANSLATED FILES // 46 | if processingData.translatedLanguages.length > 0 47 | p#translationsHeader Translations: 48 | // translated VTT files 49 | each language in processingData.translatedLanguages 50 | .links 51 | span VTT (#{language}) 52 | a(href=`${filePathWithoutExtension}_${language}.vtt`) View 53 | a(href=`${filePathWithoutExtension}_${language}.vtt` download) Download 54 | .links 55 | a(download href=`${filePathWithoutExtension}${processingData.fileExtension}`) Download File 56 | .fileDetails 57 | each property, value in processingData 58 | p #{value}: #{property} 59 | #addTranslation 60 | a(href=`/player/` + renderedFilename + `/add` style="text-decoration:none;") 61 | p(style="color:rgb(22, 29, 29);") Add Here 62 | //p Filename: #{processingData.filename} 63 | if userAuthed 64 | form(action='/player/#{renderedFilename}/keepMedia?password=#{password}&keepMedia=true' method='POST') 65 | // form elements go here 66 | button(type='submit') Keep Media True 67 | br 68 | form(action='/player/#{renderedFilename}/keepMedia?password=#{password}&keepMedia=false' method='POST') 69 | // form elements go here 70 | button(type='submit') Keep Media False 71 | br 72 | 73 | //a(href='/player/#{renderedFilename}/add?password=#{password}&keepMedia=true' style="text-decoration:none;") 74 | // p(style="") Add Here 75 | //a(href=`/player/` + renderedFilename + `/add` style="text-decoration:none;") 76 | // p(style="color:rgb(22, 29, 29);") Add Here 77 | 78 | script(src = 'https://cdn.plyr.io/3.7.2/plyr.js') 79 | link(rel='stylesheet' href='https://cdnjs.cloudflare.com/ajax/libs/plyr/3.7.2/plyr.css' integrity='sha512-SwLjzOmI94KeCvAn5c4U6gS/Sb8UC7lrm40Wf+B0MQxEuGyDqheQHKdBmT4U+r+LkdfAiNH4QHrHtdir3pYBaw==' crossorigin='anonymous' referrerpolicy='no-referrer') 80 | script. 81 | l = console.log; 82 | 83 | let player; 84 | let subtitlesFontSize, subtitlesLineHeight; 85 | 86 | const language = '#{processingData.language}'; 87 | const languagesToLoop = !{JSON.stringify(languagesToLoop)}; 88 | const allLanguages = !{JSON.stringify(allLanguages)}; 89 | const filename = '#{renderedFilename}'; 90 | 91 | l('all languages'); 92 | l(allLanguages); 93 | 94 | $(document).ready(function () { 95 | 96 | let bottomTextShown = true; 97 | $('#showHideControls').click(function(){ 98 | 99 | if(bottomTextShown){ 100 | $('.downloadLinks').hide() 101 | $('.fileDetails').hide() 102 | $('#addSubtitles').hide() 103 | bottomTextShown = false; 104 | $(this).text('Show Text'); 105 | 106 | } else { 107 | $('.downloadLinks').show() 108 | $('.fileDetails').show() 109 | $('#addSubtitles').show() 110 | bottomTextShown = true; 111 | $(this).text('Hide Text'); 112 | } 113 | l('clicked!'); 114 | }) 115 | 116 | // make all links open in new tab 117 | const anchorTags = document.querySelectorAll('a'); 118 | anchorTags.forEach((anchor) => { 119 | anchor.setAttribute('target', '_blank'); 120 | }); 121 | 122 | $('#increaseSize').click(function (event) { 123 | adjustFontSize('increase'); 124 | }); 125 | 126 | $('#decreaseSize').click(function (event) { 127 | adjustFontSize('decrease'); 128 | }); 129 | 130 | $('#increaseLineHeight').click(function (event) { 131 | adjustLineHeight('increase'); 132 | }); 133 | 134 | $('#decreaseLineHeight').click(function (event) { 135 | adjustLineHeight('decrease'); 136 | }); 137 | 138 | console.log("ready!"); 139 | var controls = 140 | [ 141 | 'progress', // The progress bar and scrubber for playback and buffering 142 | 'play-large', // The large play button in the center 143 | // 'restart', // Restart playback 144 | // 'rewind', // Rewind by the seek time (default 10 seconds) 145 | 'play', // Play/pause playback 146 | // 'fast-forward', // Fast forward by the seek time (default 10 seconds) 147 | 'volume', // Volume control 148 | 'volume-slider', 149 | 'current-time', // The current time of playback 150 | 'duration', // The full duration of the media 151 | 'mute', // Toggle mute 152 | 'captions', // Toggle captions 153 | 'settings', // Settings menu 154 | // 'pip', // Picture-in-picture (currently Safari only) 155 | // 'airplay', // Airplay (currently Safari only) 156 | // 'download', // Show a download button with a link to either the current source or a custom URL you specify in your options 157 | 'fullscreen' 158 | ]; 159 | 160 | player = new Plyr('#player', { 161 | disableContextMenu: true, 162 | controls, 163 | captions: { active: true, language: 'auto', update: true }, 164 | settings: ['captions', 'secondCaptions'] 165 | }); 166 | 167 | player.on('ready', event => { 168 | // change position of volume button 169 | $('.plyr__volume').append($('.plyr__volume').children('').get().reverse()); 170 | 171 | // without this timeout it doesn't work properly 172 | setTimeout(function(){ 173 | setStoredLineHeightAndFontSizeIfExists(); 174 | }, 200) 175 | 176 | }); 177 | 178 | let intervalId; 179 | player.on('playing', event => { 180 | l('playing'); 181 | intervalId = setInterval(function () { 182 | // Run your function here 183 | setStoredVideoProgress(); 184 | }, 5000); 185 | }) 186 | 187 | player.on('pause', event => { 188 | clearInterval(intervalId); 189 | setStoredVideoProgress(); 190 | l('pause'); 191 | }) 192 | 193 | player.on('ended', event => { 194 | clearInterval(intervalId); 195 | setStoredVideoProgress(true); 196 | l('ended'); 197 | }) 198 | 199 | // ChatGPT says this should work but doesn't for me 200 | // player.on('pause, ended', event => { 201 | // clearInterval(intervalId); 202 | // l('pause or ended!'); 203 | // }) 204 | 205 | // l('captions'); 206 | 207 | player.on('loadeddata', () => { 208 | const currentStoredProgress = getStoredVideoProgress()[filename]; 209 | l('currentStoredProgress', currentStoredProgress); 210 | if (currentStoredProgress) { 211 | player.currentTime = currentStoredProgress; 212 | } 213 | }) 214 | 215 | // probably better to do on plyr ready if available as event 216 | setTimeout(function(){ 217 | 218 | if (allLanguages.length > 1) { 219 | createSecondCaptionsSetting(); 220 | } 221 | 222 | $(function () { 223 | $('.plyr__captions').draggable({ 224 | containment: 'plyr', 225 | // drag: function (event, ui) { 226 | // } 227 | }); 228 | }); 229 | 230 | 231 | const captionsIsPressed = $('button[data-plyr="captions"]')?.hasClass('plyr__control--pressed') 232 | 233 | if (!captionsIsPressed) { 234 | $('button[data-plyr="captions"]').click(); 235 | } 236 | 237 | let haveSetupSecondCaptions = false; 238 | // set secondsDefault language 239 | player.on('playing', function(){ 240 | const currentSecondLanguageDefaults = getSecondCaptionsDefaults() 241 | // l('currentSecondLanguageDefaults', currentSecondLanguageDefaults) 242 | // l(language); 243 | if(!haveSetupSecondCaptions){ 244 | setTimeout(function () { 245 | // open menu 246 | $('.plyr__menu').children().eq(0).click() 247 | const presetLanguage = currentSecondLanguageDefaults[language]; 248 | if (presetLanguage) { 249 | // set preset value 250 | $(`span#languageValue[value="${presetLanguage}"]`).parent().click(); 251 | // close menu 252 | $('.plyr__menu').children().eq(0).click() 253 | haveSetupSecondCaptions = true; 254 | } 255 | }, 10) 256 | } 257 | }) 258 | 259 | player.on('cuechange', function (event) { 260 | const text = event.detail.plyr.captions.currentTrackNode.activeCues[0]?.text; 261 | const thing = getCurrentLanguageAndTrack() 262 | // l(thing); 263 | 264 | const selectedTranslation = $("#languageName[aria-checked='true'] > span[id*='languageValue']").attr("value"); 265 | if(selectedTranslation && selectedTranslation !== 'Disabled') { 266 | const translation = findTextFromIndexNumber(selectedTranslation, thing.indexNumber); 267 | const originalText = $('span.plyr__caption').text(); 268 | 269 | $('span.plyr__caption').text(`${originalText}\n${translation || ''}`); 270 | } 271 | 272 | // balance text not working at the moment 273 | if(subtitlesFontSize){ 274 | $('.plyr__caption').css('font-size', subtitlesFontSize); 275 | } 276 | 277 | if(subtitlesLineHeight){ 278 | $('.plyr__caption').css('line-height', subtitlesLineHeight); 279 | } 280 | // l(text); 281 | }) 282 | }, 200) 283 | }); 284 | include js/secondCaptions 285 | include js/captionsDisplay 286 | include js/videoProgress 287 | 288 | -------------------------------------------------------------------------------- /views/player/styles-player.pug: -------------------------------------------------------------------------------- 1 | style. 2 | 3 | main { 4 | display: flex; 5 | flex-direction: column; 6 | gap: 0.5em; 7 | } 8 | 9 | video { 10 | position: relative; 11 | inset: 0; 12 | max-height: 100vh; 13 | max-width: 100vw; 14 | margin: auto; 15 | object-fit: contain; 16 | height: 100%; 17 | width: 100%; 18 | } 19 | 20 | .pulled-right { 21 | max-width: 500px; 22 | margin-right: 0px; 23 | width: 200px; 24 | position: relative; 25 | float: right; 26 | top: -6px; 27 | } 28 | 29 | .video-title { 30 | font-size: 58px; 31 | margin-top: 25px; 32 | } 33 | 34 | .hide-cursor { 35 | cursor: none; 36 | } 37 | 38 | .plyr { 39 | max-height: 94.6vh; 40 | } 41 | 42 | #back-button { 43 | font-size: 14px; 44 | margin-top: 12px; 45 | } 46 | 47 | #download-button { 48 | font-size: 14px; 49 | margin-top: 5px; 50 | } 51 | 52 | .upload-details-holder { 53 | /*height: 1300px;*/ 54 | } 55 | 56 | .upload-details h4:not(.description) { 57 | font-size: 35px; 58 | } 59 | 60 | .vjs-text-track-display div div { 61 | font-size: 40px !important; 62 | } 63 | 64 | .video-js { 65 | max-height: 400px; 66 | } 67 | 68 | .bottomSection { 69 | color: #fff; 70 | padding-inline: 1.25em; 71 | font-size: 1rem; 72 | } 73 | 74 | .buttons { 75 | width: fit-content; 76 | display: flex; 77 | gap: 0.5em; 78 | } 79 | 80 | .buttons .btn { 81 | margin: 0; 82 | border: 0; 83 | margin-right: 8px; 84 | font-size: 14px; 85 | } 86 | 87 | .buttons :where(#increaseSize:hover, #increaseLineHeight:hover) { 88 | color: #55ed55; 89 | } 90 | 91 | .buttons :where(#decreaseSize:hover, .buttons #decreaseLineHeight:hover) { 92 | color: #df2b2b; 93 | } 94 | 95 | .downloadLinks { 96 | display: flex; 97 | flex-direction: column; 98 | gap: 0.5em; 99 | } 100 | 101 | #translationsHeader { 102 | margin-bottom: 1px; 103 | } 104 | 105 | .links { 106 | display: flex; 107 | gap: 1em; 108 | margin-top: 5px; 109 | } 110 | 111 | .downloadLinks, .fileDetails { 112 | font-size: 14px; 113 | } 114 | 115 | .startAnotherTranscription { 116 | margin: 0 0; 117 | } 118 | 119 | @media (min-width: 320px) { 120 | /* smartphones, portrait iPhone, portrait 480x320 phones (Android) */ 121 | } 122 | 123 | @media (min-width: 480px) { 124 | /* smartphones, Android phones, landscape iPhone */ 125 | } 126 | 127 | 128 | 129 | /** larger than this size **/ 130 | @media (min-width: 320px) { 131 | 132 | .plyr__captions .plyr__caption { 133 | font-size: 21px; 134 | line-height: 33px; 135 | } 136 | } 137 | 138 | @media (min-width: 801px) { 139 | 140 | .plyr__captions .plyr__caption { 141 | font-size: 21px; 142 | line-height: 33px; 143 | } 144 | /* tablet, landscape iPad, lo-res laptops ands desktops */ 145 | } 146 | 147 | @media (min-width: 1025px) { 148 | /* big landscape tablets, laptops, and desktops */ 149 | } 150 | 151 | /* not mobile */ 152 | @media (min-width: 1281px) { 153 | .plyr__captions .plyr__caption { 154 | font-size: 21px; 155 | line-height: 33px; 156 | } 157 | /* hi-res laptops and desktops */ 158 | } 159 | 160 | @media (max-width: 900px) { 161 | .plyr__volume { 162 | display: none !important; 163 | } 164 | 165 | .plyr__time--current, .plyr__time--duration { 166 | font-size: 24px !important; 167 | } 168 | 169 | body { 170 | //- font-size: 28px; 171 | } 172 | 173 | .plyr__control svg { 174 | width: 53px !important; 175 | height: 53px !important; 176 | } 177 | 178 | 179 | button[data-plyr="language"], button[data-plyr="settings"] { 180 | font-size: 28px !important; 181 | } 182 | 183 | /* hi-res laptops and desktops */ 184 | } 185 | 186 | .plyr__poster { 187 | display: none; 188 | } 189 | 190 | .plyr .plyr__captions { 191 | display: inline-table !important; 192 | text-align: left !important; 193 | cursor: pointer; 194 | max-width: 99%; 195 | z-index: 9999; 196 | padding: 0px; 197 | width: unset !important; 198 | position: absolute; 199 | bottom: 68px; 200 | } 201 | 202 | .plyr:not(.plyr--hide-controls) .plyr__controls:not(:empty)~.plyr__captions { 203 | transform: unset !important; 204 | } 205 | 206 | .plyr__caption { 207 | text-wrap: balance; 208 | display: block; 209 | } 210 | 211 | .plyr__controls { 212 | flex-wrap: wrap; 213 | justify-content: left !important; 214 | } 215 | 216 | .plyr__progress__container { 217 | flex-basis: 100% !important; 218 | } 219 | 220 | .plyr__progress { 221 | max-width: 99%; 222 | text-align: center; 223 | margin: 0 auto; 224 | margin-bottom: 5px; 225 | } 226 | 227 | button[data-plyr=captions] { 228 | margin-left: auto !important; 229 | } 230 | 231 | input[data-plyr=seek], input[data-plyr=volume] { 232 | cursor: pointer; 233 | } 234 | -------------------------------------------------------------------------------- /views/queue.pug: -------------------------------------------------------------------------------- 1 | extends layout 2 | 3 | block content 4 | style. 5 | p { 6 | margin: 2px auto; 7 | } 8 | 9 | 10 | div 11 | h1 Queue 12 | 13 | each queueItem in queueData 14 | each val, key in queueItem 15 | p= key + ': ' + val 16 | br 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /views/stats/stats.pug: -------------------------------------------------------------------------------- 1 | extends ../layout 2 | 3 | block content 4 | style. 5 | video { 6 | top: 0px; 7 | } 8 | 9 | #transcriptionAmountTable tbody tr th, #transcriptionTimeTable tbody tr th { 10 | text-align: left; 11 | width: 168px; 12 | } 13 | 14 | #transcriptionAmountTable tbody tr th { 15 | text-align: left; 16 | width: 100px; 17 | } 18 | 19 | #languageName { 20 | width: 155px; 21 | text-align: left; 22 | } 23 | 24 | 25 | div 26 | h1 Stats 27 | 28 | h2 Completed Transcriptions 29 | 30 | table#transcriptionAmountTable 31 | tbody 32 | tr 33 | th Last Hour 34 | th Last Day 35 | th Last Week 36 | th Last Month 37 | th All-Time 38 | tr 39 | td #{stats.lastHour} 40 | td #{stats.last24h} 41 | td #{stats.lastWeek} 42 | td #{stats.lastMonth} 43 | td #{stats.allTime} 44 | 45 | br 46 | h2 Transcription Time 47 | 48 | table#transcriptionTimeTable 49 | tbody 50 | tr 51 | th Last Hour 52 | th Last 24h 53 | th Last Week 54 | th Last Month 55 | th All-Time 56 | tr 57 | td #{transcriptionTime.lastHour} 58 | td #{transcriptionTime.last24h} 59 | td #{transcriptionTime.lastWeek} 60 | td #{transcriptionTime.lastMonth} 61 | td #{transcriptionTime.allTime} 62 | 63 | 64 | br 65 | h2 Transcribed Languages 66 | table#languages 67 | tbody 68 | tr 69 | th#languageName Language Name 70 | th Amount 71 | 72 | each pair in Object.entries(languages) 73 | tr 74 | td #{pair[0]} 75 | td #{pair[1]} 76 | //li= pair[0] + ': ' + pair[1] 77 | // each language in languages 78 | // tr 79 | // td #{language} 80 | 81 | //td #{languages.language} 82 | //td #{transcriptionTime.lastWeek} 83 | //td #{transcriptionTime.lastMonth} 84 | //td #{transcriptionTime.allTime} 85 | 86 | 87 | 88 | script. 89 | l = console.log; 90 | 91 | $(document).ready(function () { 92 | 93 | }); 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /views/styles/styles-global.pug: -------------------------------------------------------------------------------- 1 | style. 2 | html { 3 | scroll-behavior: smooth; 4 | } 5 | 6 | body { 7 | background-color: rgb(22, 29, 29); 8 | padding: 0; 9 | margin: 0; 10 | } 11 | 12 | * { 13 | box-sizing: border-box; 14 | } 15 | 16 | .container { 17 | max-width: 500px; 18 | margin: 140px auto; 19 | } 20 | 21 | .container h1 { 22 | text-align: center; 23 | color: white; 24 | } 25 | 26 | .no-pointer-events { 27 | pointer-events: none; 28 | } 29 | 30 | .btn { 31 | width: fit-content; 32 | display: inline-block; 33 | padding: 0.5em 0; 34 | margin: 0 auto; 35 | background-color: transparent; 36 | font-size: inherit; 37 | color: inherit; 38 | border-color: currentColor; 39 | text-decoration: underline; 40 | cursor: pointer; 41 | transition: all 200ms ease; 42 | } 43 | 44 | a { 45 | color: inherit; 46 | } 47 | 48 | .hidden { 49 | display: none; 50 | } --------------------------------------------------------------------------------