Skip to content

Instantly share code, notes, and snippets.

@SirOlaf
Last active April 20, 2025 11:20
Show Gist options
  • Save SirOlaf/173c08fee56b2b073a3aa87a76c0fdf8 to your computer and use it in GitHub Desktop.
Save SirOlaf/173c08fee56b2b073a3aa87a76c0fdf8 to your computer and use it in GitHub Desktop.
Bootlegged migaku player
// ==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()
@825i
Copy link

825i commented Mar 23, 2025

My firstborn is going to be called SirOlaf. You my man deserve a medal.

Only minor gripe is that the "browse..." button for video files shows nothing on Windows 11 until I click "All Files".
Plus sometimes it still wants to display the original subtitles instead of those from the subtitle file loaded.

Otherwise this is * chef's kiss *

@SirOlaf
Copy link
Author

SirOlaf commented Mar 26, 2025

My firstborn is going to be called SirOlaf. You my man deserve a medal.

Only minor gripe is that the "browse..." button for video files shows nothing on Windows 11 until I click "All Files". Plus sometimes it still wants to display the original subtitles instead of those from the subtitle file loaded.

Otherwise this is * chef's kiss *

For your first problem I got rid of the video file requirement in 1.10, no idea why chrome doesn't realize there's more than mp4 or whatever they expect.
For the second point I am gonna need more info, should work as long as you always toggle the autogenerated subs from youtube back and forth.

I do know there is an issue (at least on my linux system) that makes chrome completely unable to load files with special characters in the filename, so anything japanese or more strictly not in the ASCII charset. No idea why that happens and it's not limited to bootleg player.

@825i
Copy link

825i commented Mar 26, 2025

Ok with 1.10 it seems to work pretty much flawlessly. Not sure what the issue was before. I hope I don't have to edit this again. It's only been 10 minutes since I updated but let's see if it breaks. My filenames have been nothing more crazy than:

Bocchi the Rock! - 01 (BD 1920x1080 x265-10Bit Flac)
[HR] Hinako Note 01 [1080p][x265]
[LNS-Tsundere] Shigatsu wa Kimi no Uso - 01 [BDRip h264 1920x1080 10bit FLAC][5506B58E]

EDIT: It seems to play fine with Japanese filenames too on Windows 11 Pro eg. 第1話.mkv plays perfectly.

It seems to work consistently if I add the video first, then add the subs, then toggle the subs in the extension settings.

I've been using for example this Youtube video to overwrite: https://www.youtube.com/watch?v=I0J-K5u4y24

FYI we've been discussing this here, but maybe there's not much else to add now that it seems to work: killergerbah/asbplayer#662

I made my own Userscript for Asbplayer to remove the persistent Migaku GrabbyTabby: killergerbah/asbplayer#662 (comment)

Youtube doesn't have this problem though thankfully.

My only other issue is that Lemonfox is not very good at AI generated subtitles but that is their fault and it's great you still support it anyway.

@SirOlaf
Copy link
Author

SirOlaf commented Mar 26, 2025

Yeah not sure, all seems fine. And it might not solely be the fault of lemonfox, I resample the video audio to decrease upload time, could adjust that.
Also, you have reminded me that I wanted to make it load subs without having to toggle autogenerated so I did that in 1.12 now.

@SirOlaf
Copy link
Author

SirOlaf commented Mar 26, 2025

Ok with 1.10 it seems to work pretty much flawlessly. Not sure what the issue was before. I hope I don't have to edit this again. It's only been 10 minutes since I updated but let's see if it breaks. My filenames have been nothing more crazy than:

Bocchi the Rock! - 01 (BD 1920x1080 x265-10Bit Flac)
[HR] Hinako Note 01 [1080p][x265]
[LNS-Tsundere] Shigatsu wa Kimi no Uso - 01 [BDRip h264 1920x1080 10bit FLAC][5506B58E]

EDIT: It seems to play fine with Japanese filenames too on Windows 11 Pro eg. 第1話.mkv plays perfectly.

It seems to work consistently if I add the video first, then add the subs, then toggle the subs in the extension settings.

I've been using for example this Youtube video to overwrite: https://www.youtube.com/watch?v=I0J-K5u4y24

FYI we've been discussing this here, but maybe there's not much else to add now that it seems to work: killergerbah/asbplayer#662

I made my own Userscript for Asbplayer to remove the persistent Migaku GrabbyTabby: killergerbah/asbplayer#662 (comment)

Youtube doesn't have this problem though thankfully.

My only other issue is that Lemonfox is not very good at AI generated subtitles but that is their fault and it's great you still support it anyway.

Also if you end up running into issues with srt files again, please send them to me privately so I can take a look. Maybe there's another bug in my very improper srt parser.

@HamadTheIronside
Copy link

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 🙏

@SirOlaf
Copy link
Author

SirOlaf commented Mar 29, 2025

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.

@SirOlaf
Copy link
Author

SirOlaf commented Apr 16, 2025

New update removes the need to find a video longer than the one you want to play

@825i
Copy link

825i commented Apr 20, 2025

.

@SirOlaf
Copy link
Author

SirOlaf commented Apr 20, 2025

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