ARRFLIX/bin/inject-shim.py

219 lines
9.1 KiB
Python

#!/usr/bin/env python3
"""
Inject the ARRFLIX runtime branding shim into web-overrides/index.html.
Idempotent: if the shim is already present, exits 0 with a notice.
"""
import sys, pathlib, re
ROOT = pathlib.Path(__file__).resolve().parent.parent
TARGET = ROOT / "web-overrides" / "index.html"
MARKER_BEGIN = "/* ARRFLIX-SHIM-BEGIN */"
MARKER_END = "/* ARRFLIX-SHIM-END */"
SHIM = MARKER_BEGIN + r"""
(function(){
var TITLE = 'ARRFLIX';
var BARE_RE = /^Jellyfin$/i;
/* === English-lockdown (synchronous, runs before Jellyfin bundle) ===
Pins UI locale to en-US so the SPA never reads navigator.language
or the user's stored preference. Belt-and-braces against:
- localStorage keys the SPA reads on boot
- navigator.language / navigator.languages getters
- fetch / XHR Accept-Language header (best-effort; most browsers
block JS from setting it, but Jellyfin sometimes does)
- user-config save round-trip (rewrite UICulture → en-US before send) */
try {
var LS_KEYS = ['appLanguage','selectedlanguage','selectedlocale','language','locale','culture'];
for (var i=0;i<LS_KEYS.length;i++){
try { localStorage.setItem(LS_KEYS[i], 'en-US'); } catch(e){}
}
} catch(e){}
try {
var EN = ['en-US','en'];
Object.defineProperty(Navigator.prototype, 'language', { get:function(){return 'en-US';}, configurable:true });
Object.defineProperty(Navigator.prototype, 'languages', { get:function(){return EN.slice();}, configurable:true });
} catch(e){
/* fallback for engines that won't let us redefine on the prototype */
try { Object.defineProperty(navigator, 'language', { get:function(){return 'en-US';}, configurable:true }); } catch(e2){}
try { Object.defineProperty(navigator, 'languages', { get:function(){return ['en-US','en'];}, configurable:true }); } catch(e2){}
}
/* fetch wrapper: strip Accept-Language on outbound requests, and rewrite
any user-config save body so UICulture is pinned to en-US. */
try {
if (window.fetch) {
var _origFetch = window.fetch;
window.fetch = function(input, init){
try {
init = init || {};
/* strip Accept-Language if present on a plain object headers init */
if (init.headers) {
if (init.headers instanceof Headers) {
try { init.headers.delete('Accept-Language'); } catch(e){}
} else if (typeof init.headers === 'object') {
for (var k in init.headers){ if (k && k.toLowerCase() === 'accept-language') { try { delete init.headers[k]; } catch(e){} } }
}
}
/* rewrite user-config save: POST /Users/{id}/Configuration */
var url = (typeof input === 'string') ? input : (input && input.url) || '';
var method = (init.method || (input && input.method) || 'GET').toUpperCase();
if (url && /\/Users\/[^/]+\/Configuration(\?|$)/.test(url) && method === 'POST' && init.body) {
try {
var body = init.body;
if (typeof body === 'string') {
var obj = JSON.parse(body);
if (obj && typeof obj === 'object') {
obj.UICulture = 'en-US';
init.body = JSON.stringify(obj);
}
}
} catch(e){}
}
} catch(e){}
return _origFetch.call(this, input, init);
};
}
} catch(e){}
/* XHR wrapper: strip Accept-Language; rewrite user-config save body. */
try {
if (window.XMLHttpRequest) {
var _open = XMLHttpRequest.prototype.open;
var _setHeader = XMLHttpRequest.prototype.setRequestHeader;
var _send = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method, url){
this.__arrflix_method = (method || 'GET').toUpperCase();
this.__arrflix_url = url || '';
return _open.apply(this, arguments);
};
XMLHttpRequest.prototype.setRequestHeader = function(name, value){
if (name && String(name).toLowerCase() === 'accept-language') return;
return _setHeader.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body){
try {
if (this.__arrflix_url && /\/Users\/[^/]+\/Configuration(\?|$)/.test(this.__arrflix_url) && this.__arrflix_method === 'POST' && typeof body === 'string') {
try {
var obj = JSON.parse(body);
if (obj && typeof obj === 'object') {
obj.UICulture = 'en-US';
body = JSON.stringify(obj);
}
} catch(e){}
}
} catch(e){}
return _send.call(this, body);
};
}
} catch(e){}
/* Re-pin localStorage on every visibility change (SPA may rewrite on user save) */
function pinLocale(){
try {
var L = ['appLanguage','selectedlanguage','selectedlocale','language','locale','culture'];
for (var i=0;i<L.length;i++){ try { if (localStorage.getItem(L[i]) !== 'en-US') localStorage.setItem(L[i], 'en-US'); } catch(e){} }
} catch(e){}
}
/* === end english-lockdown synchronous block === */
function getFavicon(){
var l = document.querySelector('link[rel="shortcut icon"], link[rel="icon"]');
return l && l.href ? l.href : null;
}
function lockTitle(){
try {
var t = document.title || '';
if (BARE_RE.test(t)) { document.title = TITLE; return; }
if (/Jellyfin/i.test(t)) {
var cleaned = t.replace(/\s*[-|]\s*Jellyfin\s*$/i, '').replace(/Jellyfin/gi, TITLE);
if (!cleaned) { document.title = TITLE; }
else if (!/ARRFLIX/i.test(cleaned)) { document.title = cleaned + ' - ' + TITLE; }
else { document.title = cleaned; }
}
} catch(e){}
}
function lockFavicon(){
try {
var fav = getFavicon();
if (!fav || fav.indexOf('data:image') !== 0) return;
var icons = document.querySelectorAll('link[rel*="icon"]');
for (var i=0;i<icons.length;i++){ if (icons[i].href !== fav) icons[i].href = fav; }
} catch(e){}
}
function nukeSettings(){
try {
var nodes = document.querySelectorAll('a[href*="mypreferencesmenu"], [to*="mypreferencesmenu"]');
for (var i=0;i<nodes.length;i++){
var el = nodes[i];
var p = el.closest && (el.closest('li, .MuiListItem-root, [role="menuitem"]')) || el;
if (p && p.style && p.style.display !== 'none') p.style.display = 'none';
}
} catch(e){}
}
function start(){
lockTitle(); lockFavicon(); nukeSettings(); pinLocale();
try {
var head = document.head || document.querySelector('head');
if (head && window.MutationObserver) {
new MutationObserver(function(){ lockTitle(); lockFavicon(); }).observe(head, { childList:true, subtree:true, characterData:true, attributes:true, attributeFilter:['href'] });
}
} catch(e){}
try {
if (document.body && window.MutationObserver) {
new MutationObserver(function(){ nukeSettings(); }).observe(document.body, { childList:true, subtree:true });
}
} catch(e){}
setInterval(function(){
var t = document.title || '';
if (BARE_RE.test(t) || /Jellyfin/i.test(t)) lockTitle();
var fav = getFavicon();
if (fav && fav.indexOf('data:image') !== 0) lockFavicon();
nukeSettings();
pinLocale();
}, 1000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once:true });
} else { start(); }
if ('serviceWorker' in navigator) {
try {
navigator.serviceWorker.getRegistrations().then(function(regs){
regs.forEach(function(r){
try {
var url = (r.active && r.active.scriptURL) || '';
if (url.indexOf('serviceworker.js') !== -1) { r.unregister(); }
} catch(e){}
});
}).catch(function(){});
if (window.caches && caches.keys) {
caches.keys().then(function(keys){ keys.forEach(function(k){ caches.delete(k); }); }).catch(function(){});
}
} catch(e){}
}
})();
""" + MARKER_END
WRAPPED = "<script>" + SHIM + "</script>"
def main():
html = TARGET.read_text(encoding="utf-8")
if MARKER_BEGIN in html:
# Replace the existing shim block in place
pattern = re.compile(r"<script>" + re.escape(MARKER_BEGIN) + r".*?" + re.escape(MARKER_END) + r"</script>", re.DOTALL)
# Use a callable replacement to bypass backreference parsing in WRAPPED
new_html, n = pattern.subn(lambda _m: WRAPPED, html, count=1)
if n != 1:
print("ERROR: shim markers present but could not match for replacement", file=sys.stderr)
sys.exit(2)
TARGET.write_text(new_html, encoding="utf-8")
print(f"Replaced existing shim ({len(WRAPPED)} chars).")
return
# Insert immediately after <head>
needle = "<head>"
idx = html.find(needle)
if idx < 0:
print("ERROR: <head> not found in target", file=sys.stderr)
sys.exit(1)
insert_at = idx + len(needle)
new_html = html[:insert_at] + WRAPPED + html[insert_at:]
TARGET.write_text(new_html, encoding="utf-8")
print(f"Inserted shim ({len(WRAPPED)} chars) after <head> at offset {insert_at}.")
if __name__ == "__main__":
main()