Last active
April 26, 2025 14:15
-
-
Save SirOlaf/173c08fee56b2b073a3aa87a76c0fdf8 to your computer and use it in GitHub Desktop.
Bootlegged migaku player
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==UserScript== | |
// @name Bootlegged migaku player | |
// @namespace Violentmonkey Scripts | |
// @match https://www.youtube.com/watch* | |
// @grant none | |
// @version 1.16 | |
// @author SirOlaf | |
// @description 3/1/2025, 10:56:07 PM | |
// ==/UserScript== | |
const confignameLemonfoxApiKey = "bootlegplayer_lemonfox_api_key" | |
const confignameSubgenLang = "bootlegplayer_lemonfox_subgen_lang" | |
function configGetLemonfoxApiKey() { | |
const configEntry = localStorage.getItem(confignameLemonfoxApiKey); | |
return configEntry; | |
} | |
function configSetLemonfoxApiKey(val) { | |
localStorage.setItem(confignameLemonfoxApiKey, val); | |
} | |
function configGetSubgenLang() { | |
const configEntry = localStorage.getItem(confignameSubgenLang); | |
return configEntry; | |
} | |
function configSetSubgenLang(val) { | |
localStorage.setItem(confignameSubgenLang, val); | |
} | |
function isNumeric(value) { | |
return /^\d+$/.test(value); | |
} | |
function newDiv() { | |
return document.createElement("div"); | |
} | |
function queryMigakuShadowDom() { | |
return document.querySelector("#MigakuShadowDom").shadowRoot; | |
} | |
function srtToVtt(x) { | |
// TODO: Write a better converter | |
var result = []; | |
var skipDigit = true; | |
for (const line of x.split(/\r?\n/)) { | |
if (line.length == 0) { | |
skipDigit = true; | |
} else { | |
if (skipDigit) { | |
if (!isNumeric(line)) { | |
// TODO: Error | |
console.log("Malformed srt file") | |
return null; | |
} | |
skipDigit = false; | |
} else { | |
if (line.includes(" --> ")) { | |
result.push(line.replaceAll(",", ".")) | |
} else { | |
result.push(line); | |
} | |
} | |
} | |
} | |
return result.join("\n"); | |
} | |
// https://github.com/Experience-Monks/audiobuffer-to-wav | |
function audioBufferToWav(buffer) { | |
return audioBufferToWavInternal(buffer); | |
function audioBufferToWavInternal(buffer, opt) { | |
opt = opt || {} | |
var numChannels = buffer.numberOfChannels | |
var sampleRate = buffer.sampleRate | |
var format = opt.float32 ? 3 : 1 | |
var bitDepth = format === 3 ? 32 : 16 | |
var result | |
if (numChannels === 2) { | |
result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)) | |
} else { | |
result = buffer.getChannelData(0) | |
} | |
return encodeWAV(result, format, sampleRate, numChannels, bitDepth) | |
} | |
function encodeWAV(samples, format, sampleRate, numChannels, bitDepth) { | |
var bytesPerSample = bitDepth / 8 | |
var blockAlign = numChannels * bytesPerSample | |
var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample) | |
var view = new DataView(buffer) | |
/* RIFF identifier */ | |
writeString(view, 0, 'RIFF') | |
/* RIFF chunk length */ | |
view.setUint32(4, 36 + samples.length * bytesPerSample, true) | |
/* RIFF type */ | |
writeString(view, 8, 'WAVE') | |
/* format chunk identifier */ | |
writeString(view, 12, 'fmt ') | |
/* format chunk length */ | |
view.setUint32(16, 16, true) | |
/* sample format (raw) */ | |
view.setUint16(20, format, true) | |
/* channel count */ | |
view.setUint16(22, numChannels, true) | |
/* sample rate */ | |
view.setUint32(24, sampleRate, true) | |
/* byte rate (sample rate * block align) */ | |
view.setUint32(28, sampleRate * blockAlign, true) | |
/* block align (channel count * bytes per sample) */ | |
view.setUint16(32, blockAlign, true) | |
/* bits per sample */ | |
view.setUint16(34, bitDepth, true) | |
/* data chunk identifier */ | |
writeString(view, 36, 'data') | |
/* data chunk length */ | |
view.setUint32(40, samples.length * bytesPerSample, true) | |
if (format === 1) { // Raw PCM | |
floatTo16BitPCM(view, 44, samples) | |
} else { | |
writeFloat32(view, 44, samples) | |
} | |
return buffer | |
} | |
function interleave(inputL, inputR) { | |
var length = inputL.length + inputR.length | |
var result = new Float32Array(length) | |
var index = 0 | |
var inputIndex = 0 | |
while (index < length) { | |
result[index++] = inputL[inputIndex] | |
result[index++] = inputR[inputIndex] | |
inputIndex++ | |
} | |
return result | |
} | |
function writeFloat32(output, offset, input) { | |
for (var i = 0; i < input.length; i++, offset += 4) { | |
output.setFloat32(offset, input[i], true) | |
} | |
} | |
function floatTo16BitPCM(output, offset, input) { | |
for (var i = 0; i < input.length; i++, offset += 2) { | |
var s = Math.max(-1, Math.min(1, input[i])) | |
output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true) | |
} | |
} | |
function writeString(view, offset, string) { | |
for (var i = 0; i < string.length; i++) { | |
view.setUint8(offset + i, string.charCodeAt(i)) | |
} | |
} | |
} | |
function refreshSubs(mgkSubList) { | |
mgkSubList[0].children[0].click(); | |
setTimeout(() => {mgkSubList[1].children[0].click(); console.log("Clicked");}, 1000) | |
} | |
var customSubtitles = null; | |
var customVideoHandle = null; | |
var customVideoDuration = 0; | |
function requestCustomSubtitleGen() { | |
if (configGetLemonfoxApiKey() === null) { | |
window.alert("Generating subtitles in the bootleg players requires a custom api key."); | |
return; | |
} | |
if (customVideoHandle === null || customVideoDuration == 0) { | |
window.alert("Custom subtitle generation only works for custom video files."); | |
return; | |
} | |
if (configGetSubgenLang() === null || configGetSubgenLang() === "none") { | |
window.alert("You must select a language for subtitle generation.") | |
return; | |
} | |
if (!confirm(`Requested subtitle generation for language ${configGetSubgenLang()}. Please wait for either an error or a download panel.`)) { | |
return; | |
} | |
const audioCtx = new AudioContext(); | |
const sampleRate = 16000; | |
const numberOfChannels = 1; | |
var offlineAudioContext = new OfflineAudioContext(numberOfChannels, sampleRate * customVideoDuration, sampleRate); | |
const reader = new FileReader(); | |
reader.onload = function (e) { | |
audioCtx.decodeAudioData(reader.result) | |
.then(function (decodedBuffer) { | |
const source = new AudioBufferSourceNode(offlineAudioContext, { | |
buffer: decodedBuffer, | |
}); | |
source.connect(offlineAudioContext.destination); | |
return source.start(); | |
}) | |
.then(() => offlineAudioContext.startRendering()) | |
.then((renderedBuffer) => { | |
console.log("Audio rendered"); | |
const wav = audioBufferToWav(renderedBuffer); | |
console.log("Converted to wav"); | |
var blob = new window.Blob([new DataView(wav)], { | |
type: 'audio/wav' | |
}); | |
const body = new FormData(); | |
body.append('file', blob); | |
body.append('language', configGetSubgenLang()); | |
body.append('response_format', 'vtt'); | |
body.append("translate", false); | |
console.log("Sending api request") | |
fetch('https://api.lemonfox.ai/v1/audio/transcriptions', { | |
method: 'POST', | |
headers: { | |
'Authorization': 'Bearer ' + configGetLemonfoxApiKey(), | |
}, | |
body: body | |
}) | |
.then(response => { | |
if (response.status !== 200) { | |
throw new Error("Status code=" + response.status) | |
} | |
return response; | |
}) | |
.then(data => data.json()).then(data => { | |
console.log("Received a response") | |
customSubtitles = data; | |
var dlElem = document.createElement('a'); | |
dlElem.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(data)); | |
dlElem.setAttribute('download', "transcript.vtt"); | |
dlElem.style.display = 'none'; | |
document.body.appendChild(dlElem); | |
dlElem.click(); | |
window.alert("Finished generating and downloading subtitles.") | |
var mgkSubList = null; | |
for (const optionsForm of queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__group .UiFormField")) { | |
for (const label of optionsForm.querySelectorAll("label")) { | |
if (label.innerText === "Target language subtitles") { | |
mgkSubList = optionsForm.querySelectorAll(".multiselect .multiselect__element"); | |
break; | |
} | |
} | |
if (mgkSubList !== null) break; | |
} | |
if (mgkSubList === null || mgkSubList.length < 2) { | |
window.alert("Please ensure the original youtube video has autogenerated subtitles for your target language."); | |
} else { | |
refreshSubs(mgkSubList); | |
} | |
}) | |
.catch(error => { | |
console.log(error) | |
window.alert(error); | |
}); | |
}) | |
.catch(() => { | |
window.alert(`Error encountered: ${err}`); | |
}); | |
}; | |
reader.readAsArrayBuffer(customVideoHandle); | |
} | |
var srtOffsetMsInputNode = null; | |
const getSrtOffset = () => { | |
if (!srtOffsetMsInputNode) return 0; | |
const x = srtOffsetMsInputNode.valueAsNumber | |
return (x ? x : 0) / 1000 | |
} | |
function injectToolbarElements() { | |
for (const elem of queryMigakuShadowDom().querySelectorAll(".Toolbar__control__item")) { | |
if (elem.querySelector("p").innerText.includes("Generate subtitles")) { | |
const newGenButton = document.createElement("button"); | |
const origButton = elem.querySelector("button"); | |
newGenButton.setAttribute("class", "UiButton -flat -icon-only -icon-left") | |
for (const c of origButton.childNodes) { | |
newGenButton.appendChild(c.cloneNode(true)); | |
} | |
origButton.replaceWith(newGenButton); | |
newGenButton.onclick = () => { | |
requestCustomSubtitleGen(); | |
}; | |
break; | |
} | |
} | |
const column2 = queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__column")[1]; | |
const group = column2.appendChild(newDiv()); | |
group.setAttribute("class", "ToolbarVideoOptions__group"); | |
const heading = group.appendChild(document.createElement("p")); | |
heading.setAttribute("class", "UiTypo UiTypo__caption -emphasis"); | |
heading.innerText = "Custom loaders"; | |
const srtInputLabel = group.appendChild(document.createElement("p")); | |
srtInputLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle"); | |
srtInputLabel.innerText = "SRT/VTT file"; | |
const srtInput = group.appendChild(document.createElement("input")); | |
srtInput.setAttribute("type", "file"); | |
srtInput.onchange = (e) => { | |
var mgkSubList = null; | |
for (const optionsForm of queryMigakuShadowDom().querySelectorAll(".ToolbarVideoOptions__group .UiFormField")) { | |
for (const label of optionsForm.querySelectorAll("label")) { | |
if (label.innerText === "Target language subtitles") { | |
mgkSubList = optionsForm.querySelectorAll(".multiselect .multiselect__element"); | |
break; | |
} | |
} | |
if (mgkSubList !== null) break; | |
} | |
if (mgkSubList === null || mgkSubList.length < 2) { | |
window.alert("Please ensure the original youtube video has autogenerated subtitles for your target language."); | |
} else { | |
const file = e.target.files[0]; | |
const reader = new FileReader(); | |
reader.onload = function (e) { | |
console.log("Loaded subtitle file"); | |
if (e.target.result.startsWith("WEBVTT")) { | |
customSubtitles = e.target.result; | |
} else { | |
customSubtitles = srtToVtt(e.target.result); | |
} | |
refreshSubs(mgkSubList); | |
}; | |
reader.readAsText(file); | |
} | |
} | |
const vidInputLabel = group.appendChild(document.createElement("p")); | |
vidInputLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle"); | |
vidInputLabel.innerText = "Video file"; | |
const vidInput = group.appendChild(document.createElement("input")); | |
vidInput.setAttribute("type", "file"); | |
vidInput.onchange = (e) => { | |
const container = document.querySelector("#ytd-player #container"); | |
const vidNode = container.querySelector("video"); | |
customVideoHandle = e.target.files[0]; | |
customVideoDuration = 0; | |
vidNode.src = window.URL.createObjectURL(customVideoHandle); | |
const metadataCb = function () { | |
customVideoDuration = vidNode.duration; | |
vidNode.removeEventListener("loadedmetadata", metadataCb); | |
} | |
vidNode.addEventListener("loadedmetadata", metadataCb); | |
const durationCb = function() { | |
vidNode.currentTime = 0; | |
vidNode.removeEventListener("durationchange", durationCb); | |
} | |
vidNode.addEventListener("durationchange", durationCb); | |
const moviePlayer = document.querySelector("#movie_player"); | |
moviePlayer.replaceChildren(moviePlayer.querySelector(".html5-video-container")); | |
vidNode.controls = true; | |
try { | |
Object.defineProperty(vidNode, "controls", { | |
set(x) { | |
// Youtube tries to remove it | |
} | |
}) | |
Object.defineProperty(vidNode, "volume", { | |
set(x) { | |
// Youtube occasionally overrides the volume. Manually setting volume does not triggers this and continues to work normally. | |
} | |
}); | |
var {get, set} = findDescriptor(vidNode, "currentTime") | |
Object.defineProperty(vidNode, "currentTime", { | |
get() { | |
return get.call(this) + getSrtOffset() | |
}, | |
set(x) { | |
set.call(this, x - getSrtOffset()) | |
} | |
}) | |
// For audiobooks/audio only files, width and height will be 0 which makes recording fail because migaku ALWAYS tries to take a screenshot. | |
var {get: getW, set: setW} = findDescriptor(vidNode, "videoWidth"); | |
Object.defineProperty(vidNode, "videoWidth", { | |
get() { | |
const x = getW.call(this); | |
if (x < 1) return 1; | |
return x; | |
}, | |
}); | |
var {get: getH, set: setH} = findDescriptor(vidNode, "videoHeight"); | |
Object.defineProperty(vidNode, "videoHeight", { | |
get() { | |
const x = getH.call(this); | |
if (x < 1) return 1; | |
return x; | |
}, | |
}); | |
} catch (E) { | |
console.log(E); | |
} | |
vidNode.load(); | |
vidNode.currentTime = 0; | |
const ytGarbage = document.querySelector("#content #columns"); | |
if (ytGarbage) { | |
ytGarbage.remove(); | |
} | |
} | |
const apiKeyLabel = group.appendChild(document.createElement("p")) | |
apiKeyLabel.setAttribute("class", "UiTypo UiFormField__labelContainer__subtitle -emphasis"); | |
apiKeyLabel.innerText = "Lemonfox api key (autogenerated subs for injected videos)" | |
const languageSelect = group.appendChild(document.createElement("select")) | |
languageSelect.style.backgroundColor = "#472589"; | |
languageSelect.style.color = "white"; | |
const firstOptionElem = languageSelect.appendChild(document.createElement("option")); | |
firstOptionElem.setAttribute("value", "none") | |
firstOptionElem.innerText = "Select language" | |
for (const name of ["cantonese", "english", "french", "german", "japanese", "korean", "mandarin", "portuguese", "spanish", "vietnamese"].sort()) { | |
const optionElem = languageSelect.appendChild(document.createElement("option")); | |
optionElem.setAttribute("value", name); | |
optionElem.innerText = name; | |
} | |
languageSelect.onchange = () => { | |
console.log("Selected: " + languageSelect.value); | |
configSetSubgenLang(languageSelect.value) | |
}; | |
if (configGetSubgenLang() !== null) { | |
languageSelect.value = configGetSubgenLang() | |
} | |
const apiKeyInput = group.appendChild(document.createElement("input")); | |
apiKeyInput.setAttribute("class", "UiTextInput__input"); | |
apiKeyInput.value = configGetLemonfoxApiKey(); | |
apiKeyInput.onchange = () => { | |
configSetLemonfoxApiKey(apiKeyInput.value.trim()); | |
}; | |
} | |
// https://stackoverflow.com/a/38802602 | |
function findDescriptor(obj, prop){ | |
if(obj != null){ | |
return Object.hasOwnProperty.call(obj, prop)? | |
Object.getOwnPropertyDescriptor(obj, prop): | |
findDescriptor(Object.getPrototypeOf(obj), prop); | |
} | |
} | |
function injectSubtitleBrowserElements() { | |
const migakuHeader = queryMigakuShadowDom().querySelector(".SubtitleBrowser__header"); | |
for (let x of migakuHeader.querySelectorAll(":scope > *")) { | |
if (x.nodeName.toLowerCase() === "button") continue; | |
x.remove(); | |
} | |
migakuHeader.setAttribute("style", "display: flex; flex-direction: column;"); | |
const topBar = migakuHeader.appendChild(document.createElement("div")); | |
topBar.setAttribute("style", "display: flex; white-space: nowrap;") | |
// TODO: Could surely construct these buttons in a better way | |
const adjustMinusXXX = topBar.appendChild(document.createElement("button")) | |
adjustMinusXXX.innerText = "---" | |
adjustMinusXXX.setAttribute("style", "background-color: #2b2b60") | |
const adjustMinusXX = topBar.appendChild(document.createElement("button")) | |
adjustMinusXX.innerText = "--" | |
adjustMinusXX.setAttribute("style", "background-color: #2b2b60") | |
const adjustMinusX = topBar.appendChild(document.createElement("button")) | |
adjustMinusX.innerText = "-" | |
adjustMinusX.setAttribute("style", "background-color: #2b2b60") | |
srtOffsetMsInputNode = topBar.appendChild(document.createElement("input")) | |
srtOffsetMsInputNode.setAttribute("type", "number") | |
srtOffsetMsInputNode.setAttribute("style", "background-color: #2b2b60"); | |
srtOffsetMsInputNode.placeholder = "Subtitle offset ms" | |
srtOffsetMsInputNode.onkeydown = (e) => e.stopPropagation(); | |
srtOffsetMsInputNode.onkeyup = (e) => e.stopPropagation(); | |
const adjustPlusX = topBar.appendChild(document.createElement("button")) | |
adjustPlusX.innerText = "+" | |
adjustPlusX.setAttribute("style", "background-color: #2b2b60") | |
const adjustPlusXX = topBar.appendChild(document.createElement("button")) | |
adjustPlusXX.innerText = "++" | |
adjustPlusXX.setAttribute("style", "background-color: #2b2b60") | |
const adjustPlusXXX = topBar.appendChild(document.createElement("button")) | |
adjustPlusXXX.innerText = "+++" | |
adjustPlusXXX.setAttribute("style", "background-color: #2b2b60") | |
const adjustOffset = (x) => { | |
srtOffsetMsInputNode.value = srtOffsetMsInputNode.valueAsNumber ? srtOffsetMsInputNode.valueAsNumber + x : x; | |
} | |
adjustMinusXXX.onclick = () => adjustOffset(-1000); | |
adjustMinusXX.onclick = () => adjustOffset(-100); | |
adjustMinusX.onclick = () => adjustOffset(-10); | |
adjustPlusX.onclick = () => adjustOffset(10); | |
adjustPlusXX.onclick = () => adjustOffset(100); | |
adjustPlusXXX.onclick = () => adjustOffset(1000); | |
} | |
function waitForMigaku(cb) { | |
const interval = 200; | |
var i = 0; | |
var intv = setInterval(() => { | |
const x = document.querySelector("#MigakuShadowDom"); | |
if (x) { | |
const y = x.shadowRoot.querySelector(".ToolbarVideoOptions__column"); | |
if (y) { | |
clearInterval(intv); | |
cb(); | |
} | |
} | |
i += 1; | |
if (i >= 50) { | |
clearInterval(intv); | |
console.log("Could not find migaku, stopping bootstrap") | |
return; | |
} | |
}, interval); | |
} | |
function bootstrap() { | |
waitForMigaku(() => { | |
injectToolbarElements(); | |
injectSubtitleBrowserElements(); | |
jsorigStringTrim = String.prototype.trim; | |
String.prototype.trim = function () { | |
var x = this; | |
if (this.startsWith("WEBVTT") && customSubtitles !== null) { | |
console.log("Injecting new subtitles"); | |
x = customSubtitles; | |
} | |
return jsorigStringTrim.apply(x); | |
}; | |
}); | |
} | |
bootstrap() |
This script is amazing, thanks a lot, it would be better if you could open a GitHub repo for it so we can contribute to it and even donate. thanks a lot ๐
The hope is that migaku will release the real player soon enough that contributions won't be needed, but we'll see how long it takes.
New update removes the need to find a video longer than the one you want to play
.
My discord name is the same as my github name, not exactly sure what request you are talking about but feel free to send me a message there.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This script is amazing, thanks a lot, it would be better if you could open a GitHub repo for it so we can contribute to it and even donate. thanks a lot ๐