Skip to content

Instantly share code, notes, and snippets.

@nwgat
Created March 28, 2026 17:25
Show Gist options
  • Select an option

  • Save nwgat/d178ba69c2eaf727e5746d5f8f0d689e to your computer and use it in GitHub Desktop.

Select an option

Save nwgat/d178ba69c2eaf727e5746d5f8f0d689e to your computer and use it in GitHub Desktop.
nnqs
// ==UserScript==
// @name NRK TV - Native Quality Selector (Resolution & FPS Only)
// @namespace Violentmonkey Scripts
// @match *://tv.nrk.no/*
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
const UI_ID = 'nrk-native-hq-wrapper';
// --- CONFIGURATION TIERS ---
const TIERS = [
{ label: "1080p • Full HD", maxRes: 1080, maxFps: 60 },
{ label: "720p • High (50fps)", maxRes: 720, maxFps: 60 },
{ label: "720p • Low (25fps)", maxRes: 720, maxFps: 30 },
{ label: "540p • SD", maxRes: 540, maxFps: 30 }
];
let currentTierIndex = GM_getValue('nrk_quality_tier_index_v6', 0);
if (currentTierIndex < 0 || currentTierIndex >= TIERS.length) currentTierIndex = 0;
let currentLimit = TIERS[currentTierIndex];
let detectedMaxHeight = 0;
// --- 1. MANIFEST FILTERING ---
function filterManifest(text) {
if (!text.includes('#EXT-X-STREAM-INF')) return null;
const lines = text.split('\n');
const headerLines = [];
const variants = [];
let currentVariantLines = [];
let isParsingVariant = false;
for (let line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('#EXT-X-STREAM-INF')) {
isParsingVariant = true;
currentVariantLines = [line];
} else if (isParsingVariant) {
currentVariantLines.push(line);
if (!trimmed.startsWith('#') && trimmed.length > 0) {
variants.push({
lines: currentVariantLines,
...extractVariantStats(currentVariantLines)
});
isParsingVariant = false;
}
} else {
headerLines.push(line);
}
}
// Update UI max detection
let localMax = 0;
variants.forEach(v => { if(v.height > localMax) localMax = v.height; });
if (localMax > 0 && localMax !== detectedMaxHeight) {
detectedMaxHeight = localMax;
window.dispatchEvent(new CustomEvent('nrk-quality-update', { detail: localMax }));
}
// Filter Logic: Ignore bitrate, focus only on Res and FPS
const keptVariants = variants.filter(v => {
const resMatch = v.height <= currentLimit.maxRes;
// If the manifest doesn't list FPS, we assume it's safe (fallback)
// Otherwise, we check if it's over the limit (e.g., 50 > 30)
const fpsMatch = (v.fps === 0 || v.fps <= currentLimit.maxFps);
return resMatch && fpsMatch;
});
// If the FPS filter was too strict (NRK didn't provide a 25fps version for that res),
// we take the lowest available version of that resolution instead of dropping to 540p.
if (keptVariants.length === 0 && variants.length > 0) {
const sameRes = variants.filter(v => v.height === currentLimit.maxRes);
if (sameRes.length > 0) {
// Sort by bandwidth just to pick the most efficient one as a fallback
sameRes.sort((a, b) => a.bandwidth - b.bandwidth);
keptVariants.push(sameRes[0]);
} else {
keptVariants.push(variants[0]);
}
}
return [
...headerLines,
...keptVariants.map(v => v.lines.join('\n'))
].join('\n');
}
function extractVariantStats(lines) {
let height = 0, bandwidth = 0, fps = 0;
const infLine = lines.find(l => l.startsWith('#EXT-X-STREAM-INF'));
if (infLine) {
const rMatch = infLine.match(/RESOLUTION=\d+x(\d+)/);
if (rMatch) height = parseInt(rMatch[1]);
const bMatch = infLine.match(/BANDWIDTH=(\d+)/);
if (bMatch) bandwidth = parseInt(bMatch[1]);
const fMatch = infLine.match(/FRAME-RATE=([\d.]+)/);
if (fMatch) fps = parseFloat(fMatch[1]);
}
return { height, bandwidth, fps };
}
// --- 2. NETWORK INTERCEPTION ---
const { fetch: originalFetch } = window;
window.fetch = async (...args) => {
const url = (typeof args[0] === 'string') ? args[0] : args[0].url;
if (url && (url.includes('.m3u8') || url.includes('manifest'))) {
const response = await originalFetch(...args);
const clone = response.clone();
const text = await clone.text();
if (text.includes('#EXT-X-STREAM-INF')) {
const filtered = filterManifest(text);
if (filtered) {
const newHeaders = new Headers(response.headers);
newHeaders.delete('Content-Length');
return new Response(filtered, { status: 200, statusText: 'OK', headers: newHeaders });
}
}
}
return originalFetch(...args);
};
const xhrProto = XMLHttpRequest.prototype;
const responseTextDesc = Object.getOwnPropertyDescriptor(xhrProto, 'responseText');
const origOpen = xhrProto.open;
const origSend = xhrProto.send;
Object.defineProperty(xhrProto, 'responseText', { get: function() { return this._hackedResponse || responseTextDesc.get.call(this); } });
xhrProto.open = function(method, url) { this._targetUrl = url; return origOpen.apply(this, arguments); };
xhrProto.send = function() {
if (this._targetUrl && (this._targetUrl.includes('.m3u8') || this._targetUrl.includes('manifest'))) {
const fnStateChange = this.onreadystatechange;
this.onreadystatechange = function() {
if (this.readyState === 4 && this.status === 200) {
const rawText = responseTextDesc.get.call(this);
if (rawText && rawText.includes('#EXT-X-STREAM-INF')) {
const cleanText = filterManifest(rawText);
if (cleanText) this._hackedResponse = cleanText;
}
}
if (fnStateChange) fnStateChange.apply(this, arguments);
};
}
return origSend.apply(this, arguments);
};
// --- 3. UI ---
function createUI(targetContainer) {
if (document.getElementById(UI_ID)) return;
if (getComputedStyle(targetContainer).position === 'static') targetContainer.style.position = 'relative';
const wrapper = document.createElement('a');
wrapper.id = UI_ID;
wrapper.href = "#";
wrapper.style.cssText = `position: absolute; right: -110px; top: 0; height: 100%; display: flex; align-items: center; justify-content: center; gap: 8px; color: white; text-decoration: none; cursor: pointer; z-index: 9000; white-space: nowrap;`;
wrapper.innerHTML = `<span style="display: flex; align-items: center;"><svg viewBox="0 0 24 24" width="1.5em" height="1.5em" fill="currentColor"><path d="M3 17v2h6v-2H3zM3 5v2h10V5H3zM13 21v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zM21 13v-2H11v2h10zM15 9h2V7h4V5h-4V3h-2v6z"/></svg></span><span style="font-size: 14px; font-weight: 600; font-family: 'NRK Sans', sans-serif;">Kvalitet</span>`;
const menu = document.createElement('div');
menu.style.cssText = `position: absolute; top: 100%; right: 0; background: #1d1d1d; border: 1px solid #333; border-radius: 4px; padding: 5px 0; display: none; flex-direction: column; width: 220px; box-shadow: 0 4px 15px rgba(0,0,0,0.5); z-index: 10000; margin-top: 5px;`;
TIERS.forEach((tier, index) => {
const item = document.createElement('div');
item.innerText = tier.label;
const isSelected = currentTierIndex === index;
item.style.cssText = `padding: 10px 16px; font-size: 14px; color: ${isSelected ? '#00b9f2' : '#ddd'}; cursor: pointer; font-family: 'NRK Sans', sans-serif; font-weight: ${isSelected ? '600' : '400'}; border-bottom: 1px solid rgba(255,255,255,0.05);`;
item.onclick = (e) => {
e.preventDefault();
GM_setValue('nrk_quality_tier_index_v6', index);
location.reload();
};
menu.appendChild(item);
});
wrapper.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
menu.style.display = menu.style.display === 'none' ? 'flex' : 'none';
};
document.addEventListener('click', () => menu.style.display = 'none');
wrapper.appendChild(menu);
targetContainer.appendChild(wrapper);
}
setInterval(() => {
const profileLink = document.querySelector('a[data-testid="my-content-link"]') || document.querySelector('a[data-testid="my-content-login-link"]');
if (profileLink && profileLink.parentElement) createUI(profileLink.parentElement);
}, 1000);
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment