// Current settings snapshot. let whitelist = []; let whitelistHandles = []; let debugEnabled = false; // In-memory index of known member-only videos. const memberOnlyIndex = new Map(); // id -> { channelKey, hidden } const sharedIndex = new Map(); // stableId -> meta for cross-tab HUD // Data-* attribute prefix for DOM tags. const DATA_PREFIX = "chipperfluff-nobs"; // nobs = "no-bs" (member-only videos) const HUD_DOT_ID = `${DATA_PREFIX}-hud-dot`; const HUD_PANEL_ID = `${DATA_PREFIX}-hud-panel`; let generatedIdCounter = 0; let hudDirty = false; let lastHudSignature = ""; const STORAGE_KEY = "memberOnlyHidden"; const DEFAULT_MAX_ARCHIVE = 500; const MAX_SNAPSHOT_CHARS = 20000; let persistTimer = null; let contextInvalidated = false; function safeSnapshot(video) { if (typeof buildSnapshot !== "function") return ""; return buildSnapshot(video); } function stripHiddenMarkers(html) { const pattern = new RegExp( `\\s*data-${DATA_PREFIX}-[^=\\s>]+(?:=(\"[^\"]*\"|'[^']*'|[^\\s>]*))?`, "gi" ); return html .replace(pattern, "") .replace(new RegExp(`data-${DATA_PREFIX}-[^\\s>]+`, "gi"), ""); } function sanitizeSnapshotToFragment(html) { const template = document.createElement("template"); const cleaned = stripHiddenMarkers(html); template.innerHTML = cleaned; return template.content; } function forceStripDataMarkers(root) { const all = root.querySelectorAll("*"); all.forEach(el => { Array.from(el.attributes).forEach(attr => { if (attr.name.includes(`data-${DATA_PREFIX}-`)) { el.removeAttribute(attr.name); } }); }); } /* ---------------- CSS injection ---------------- */ // Inject a single style tag for hiding matched videos. function injectStyleOnce() { if (document.getElementById("member-filter-style")) return; const style = document.createElement("style"); style.id = "member-filter-style"; style.textContent = ` [data-${DATA_PREFIX}-hidden="true"] { display: none !important; } #${HUD_DOT_ID} { position: fixed; right: 18px; bottom: 18px; width: 12px; height: 12px; border-radius: 999px; background: #9b9b9b; border: 2px solid #6f6f6f; box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.12); z-index: 2147483647; cursor: pointer; } #${HUD_DOT_ID}[data-${DATA_PREFIX}-active="true"] { background: #ff9c1a; border-color: #b04a00; box-shadow: 0 0 0 3px rgba(255, 140, 0, 0.4); animation: ${DATA_PREFIX}-pulse 1.8s ease-in-out infinite; } @keyframes ${DATA_PREFIX}-pulse { 0% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(255, 140, 0, 0.6); } 70% { transform: scale(1.1); box-shadow: 0 0 0 6px rgba(255, 140, 0, 0); } 100% { transform: scale(0.9); box-shadow: 0 0 0 0 rgba(255, 140, 0, 0); } } #${HUD_PANEL_ID} { position: fixed; left: 50%; top: 50%; transform: translate(-50%, -50%); width: 520px; height: 520px; overflow: auto; padding: 0; border-radius: 12px; border: 1px solid #9b3b0c; background: radial-gradient(circle at 20% 10%, #2b2b2b, #151515 60%); color: #f5efe6; font-size: 12px; box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35); z-index: 2147483647; } #${HUD_PANEL_ID}[data-${DATA_PREFIX}-hidden="true"] { display: none; } .${DATA_PREFIX}-hud-title { font-weight: 700; margin: 0; padding: 12px 14px; text-transform: uppercase; font-size: 12px; letter-spacing: 0.6px; background: linear-gradient(90deg, #e35b14, #c9490e); border-bottom: 2px solid #b2410e; color: #fff8ee; } .${DATA_PREFIX}-hud-body { padding: 10px 12px 14px; } .${DATA_PREFIX}-channel { margin: 8px 0; padding: 6px 8px; border-radius: 10px; background: rgba(15, 15, 15, 0.55); border: 1px solid #2a2a2a; } .${DATA_PREFIX}-channel-header { display: flex; align-items: center; justify-content: space-between; gap: 8px; cursor: pointer; font-weight: 700; color: #ffd8b0; text-transform: uppercase; letter-spacing: 0.5px; font-size: 11px; } .${DATA_PREFIX}-channel-header::before { content: "▸"; margin-right: 6px; color: #8b5a2a; font-weight: 700; } details[open] > .${DATA_PREFIX}-channel-header::before { content: "▾"; } .${DATA_PREFIX}-video-list { margin: 6px 0 0; padding-left: 18px; border-left: 2px solid #3a2c22; } .${DATA_PREFIX}-hud-item { display: grid; grid-template-columns: 64px 1fr; gap: 8px; align-items: center; margin: 6px 0; padding: 6px; border-radius: 8px; background: #232323; border: 1px solid #3a2c22; } .${DATA_PREFIX}-hud-thumb { width: 64px; height: 36px; object-fit: cover; border-radius: 4px; background: #2a2a2a; } .${DATA_PREFIX}-hud-link { color: #ffb26a; text-decoration: none; font-weight: 700; } .${DATA_PREFIX}-hud-actions { display: flex; justify-content: flex-end; margin-bottom: 8px; } .${DATA_PREFIX}-hud-clear { padding: 6px 10px; border: 1px solid #4a3a2a; border-radius: 8px; background: transparent; color: #f5efe6; font-size: 11px; cursor: pointer; } .${DATA_PREFIX}-hud-link:hover { text-decoration: underline; } .${DATA_PREFIX}-snapshot { border: 1px solid #3a2c22; border-radius: 8px; padding: 8px; background: #232323; color: #f5efe6; font-size: 11px; line-height: 1.35; } .${DATA_PREFIX}-snapshot a { color: #ffb26a; text-decoration: none; } .${DATA_PREFIX}-snapshot img { max-width: 100%; height: auto; border-radius: 6px; display: block; margin-bottom: 6px; } `; document.head.appendChild(style); } injectStyleOnce(); /* ---------------- load whitelist ---------------- */ // Debug helpers (no output unless debug is enabled). function debugLog(...args) { if (!debugEnabled) return; console.log("[MemberFilter]", ...args); } function debugGroup(...args) { if (!debugEnabled) return; console.group("[MemberFilter]", ...args); } function debugGroupEnd() { if (!debugEnabled) return; console.groupEnd(); } // Normalize strings so matching is consistent. function normalizeKey(value) { return value .toLowerCase() .replace(/\u00a0/g, " ") .replace(/\s+/g, " ") .replace(/\u2022/g, "") .trim(); } // Support legacy strings and new { name, handle } entries. function normalizeWhitelist(list) { const names = []; const handles = []; (list || []).forEach(item => { if (typeof item === "string") { if (item) names.push(normalizeKey(item)); return; } const name = normalizeKey(item.name || ""); const handle = normalizeKey(item.handle || ""); if (name) names.push(name); if (handle) handles.push(handle); }); return { names, handles }; } // Read settings and re-apply visibility immediately. function loadSettings() { if (!chrome?.storage?.local) return; chrome.storage.local.get({ whitelist: [], debug: false }, data => { const { names, handles } = normalizeWhitelist(data.whitelist); whitelist = names; whitelistHandles = handles; debugEnabled = Boolean(data.debug); debugLog("settings loaded:", { whitelist, whitelistHandles, debugEnabled }); if (isWhitelistedChannelPage()) { revealAll(); return; } updateKnownVisibility(); process(); updateHudDot(); }); } loadSettings(); loadStoredIndex(); // Reload settings or shared HUD data if storage changes. chrome.storage.onChanged.addListener(changes => { if (changes[STORAGE_KEY]) { const items = changes[STORAGE_KEY].newValue || []; sharedIndex.clear(); items.forEach(item => sharedIndex.set(item.id, item)); updateHudDot(); } if (changes.whitelist || changes.debug) { loadSettings(); } }); /* ---------------- detection logic ---------------- */ // Detect member-only videos and tag them for future updates. function process(root = document) { if (isWhitelistedChannelPage()) return; const badges = root.querySelectorAll("badge-shape"); badges.forEach(badge => { if (!badge.textContent?.includes("Nur für Kanalmitglieder")) return; const video = badge.closest( "ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer" ); if (!video) return; const videoMeta = getVideoMeta(video); const title = videoMeta.title; const url = videoMeta.url; const thumb = videoMeta.thumb; const channelInfo = getChannelInfo(video); const channel = channelInfo.label || "(no channel)"; const channelUrl = channelInfo.url || ""; const channelKey = channelInfo.key; const id = videoMeta.videoId || getOrCreateVideoId(video, url); const stableId = videoMeta.videoId || getStableId(video, url, id); video.setAttribute(`data-${DATA_PREFIX}-member-only`, "true"); if (channelKey) video.setAttribute(`data-${DATA_PREFIX}-channel`, channelKey); if (id) video.setAttribute(`data-${DATA_PREFIX}-id`, id); if (id) { const existing = memberOnlyIndex.get(id); if (!existing) { memberOnlyIndex.set(id, { channelKey, channelLabel: channel, channelUrl, stableId, hidden: false, title, url, thumb }); hudDirty = true; } else { // Fill missing metadata as it becomes available. if (!existing.title && title) existing.title = title; if (!existing.url && url) existing.url = url; if (!existing.thumb && thumb) existing.thumb = thumb; if (!existing.channelLabel && channel) existing.channelLabel = channel; if (!existing.channelUrl && channelUrl) existing.channelUrl = channelUrl; if (existing.hidden) { if (!existing.snapshot) existing.snapshot = safeSnapshot(video); updateSharedIndex(existing, true); } } } const whitelisted = whitelist.includes(channelKey); debugGroup(); debugLog("Title :", title); debugLog("Channel:", channel); debugLog("URL :", url); debugLog("ID :", id || "(no id)"); debugLog("Whitelisted:", whitelisted); debugGroupEnd(); if (!whitelisted) { if (id && memberOnlyIndex.has(id)) { const meta = memberOnlyIndex.get(id); if (!meta.snapshot) meta.snapshot = safeSnapshot(video); if (!meta.hidden) hudDirty = true; meta.hidden = true; updateSharedIndex(meta, true); } video.setAttribute(`data-${DATA_PREFIX}-hidden`, "true"); } else { video.removeAttribute(`data-${DATA_PREFIX}-hidden`); if (id && memberOnlyIndex.has(id)) { const meta = memberOnlyIndex.get(id); if (meta.hidden) hudDirty = true; meta.hidden = false; updateSharedIndex(meta, false); } } if (videoMeta.needsRetry) scheduleMetaRetry(video); }); updateHudDot(); } // Apply whitelist to already-tagged member-only videos. function updateKnownVisibility() { if (isWhitelistedChannelPage()) { revealAll(); return; } for (const [id, meta] of memberOnlyIndex.entries()) { if (!meta.channelKey) continue; const video = findVideoById(id); if (!video) { memberOnlyIndex.delete(id); continue; } if (whitelist.includes(meta.channelKey)) { video.removeAttribute(`data-${DATA_PREFIX}-hidden`); if (meta.hidden) hudDirty = true; meta.hidden = false; updateSharedIndex(meta, false); } else { video.setAttribute(`data-${DATA_PREFIX}-hidden`, "true"); if (!meta.hidden) hudDirty = true; meta.hidden = true; if (!meta.snapshot) meta.snapshot = safeSnapshot(video); updateSharedIndex(meta, true); } } updateHudDot(); } // Allow full visibility on whitelisted channel pages. function isWhitelistedChannelPage() { const handle = getChannelHandleFromUrl(location.href); if (!handle) return false; return whitelistHandles.includes(normalizeKey(handle)); } // Extract @handle from a channel URL. function getChannelHandleFromUrl(url) { const match = url.match(/youtube\.com\/@([^/]+)/i); return match ? match[1] : ""; } // Remove hidden flags from any tagged videos. function revealAll() { const videos = document.querySelectorAll( `[data-${DATA_PREFIX}-hidden="true"]` ); videos.forEach(video => video.removeAttribute(`data-${DATA_PREFIX}-hidden`)); } // Choose a stable identifier for indexing. function getOrCreateVideoId(video, url) { const dataId = video.getAttribute("data-video-id"); if (dataId) return dataId; if (url) return url; const fallbackId = video.id; if (fallbackId) return fallbackId; const generated = `mf-${generatedIdCounter++}`; video.setAttribute(`data-${DATA_PREFIX}-id`, generated); return generated; } function getStableId(video, url, fallbackId) { const dataId = video.getAttribute("data-video-id"); if (dataId) return dataId; if (url) return url; return fallbackId || ""; } function getVideoMeta(video) { const linkEl = findWatchLink(video); const href = linkEl?.getAttribute("href") || ""; const videoId = getVideoIdFromHref(href) || video.getAttribute("data-video-id") || video.querySelector("[data-video-id]")?.getAttribute("data-video-id") || ""; const url = videoId ? `https://www.youtube.com/watch?v=${videoId}` : href.startsWith("http") ? href : href ? new URL(href, location.origin).toString() : ""; const title = getLinkTitle(linkEl) || getFallbackTitle(video) || "(no title)"; const thumb = (videoId ? `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg` : "") || video.querySelector("img")?.currentSrc || video.querySelector("img")?.getAttribute("src") || ""; const needsRetry = !videoId || !title || title === "(no title)"; return { title, url, thumb, videoId, needsRetry }; } function findWatchLink(video) { const links = Array.from(video.querySelectorAll("a[href]")); let best = null; for (const link of links) { const href = link.getAttribute("href") || ""; if (!href.includes("watch?v=")) continue; if (!best) best = link; const titled = getLinkTitle(link) || link.textContent?.trim(); if (titled) return link; } return best; } function getLinkTitle(link) { if (!link) return ""; const title = link.getAttribute("title") || link.getAttribute("aria-label"); return title ? title.trim() : ""; } function getFallbackTitle(video) { const titled = Array.from(video.querySelectorAll("[title]")) .map(el => el.getAttribute("title") || "") .map(text => text.trim()) .find(text => text && text.toLowerCase() !== "undefined"); if (titled) return titled; const aria = Array.from(video.querySelectorAll("[aria-label]")) .map(el => el.getAttribute("aria-label") || "") .map(text => text.trim()) .find(text => text); return aria || ""; } function scheduleMetaRetry(video) { const attr = `data-${DATA_PREFIX}-meta-retry`; const current = parseInt(video.getAttribute(attr) || "0", 10); if (current >= 3) return; video.setAttribute(attr, String(current + 1)); setTimeout(() => { process(video); }, 400 + current * 400); } function getVideoIdFromHref(href) { if (!href) return ""; const match = href.match(/[?&]v=([^&]+)/); if (match) return match[1]; const shortMatch = href.match(/youtu\.be\/([^?]+)/); if (shortMatch) return shortMatch[1]; return ""; } function buildSnapshot(video) { try { const root = video.closest( "ytd-rich-item-renderer, ytd-video-renderer, ytd-grid-video-renderer" ) || video; const clone = root.cloneNode(true); removeDataMarkers(clone); const html = stripHiddenMarkers(clone.outerHTML).trim(); if (!html) return ""; if (html.length > MAX_SNAPSHOT_CHARS) { return html.slice(0, MAX_SNAPSHOT_CHARS); } return html; } catch (error) { return ""; } } // Read the channel key from DOM or fallback to text. function getChannelKey(video) { const stored = video.getAttribute(`data-${DATA_PREFIX}-channel`); if (stored) return stored; return getChannelInfo(video).key; } function getChannelKeyFromData(channelText, channelUrl) { const handle = getChannelHandleFromUrl(channelUrl); if (handle) return normalizeKey(handle); return channelText ? normalizeKey(channelText) : ""; } function getChannelInfo(video) { const channelBlock = video.querySelector("ytd-channel-name") || video.querySelector(".ytd-channel-name"); const linkEl = channelBlock?.querySelector("a") || video.querySelector('a[href^="/@"]') || video.querySelector('a[href^="/channel/"]') || video.querySelector('a[href^="/c/"]'); const labelEl = channelBlock?.querySelector("#text") || channelBlock?.querySelector("yt-formatted-string") || channelBlock; const label = labelEl?.textContent?.trim() ?? ""; const url = linkEl?.href ?? ""; const handleFromUrl = getChannelHandleFromUrl(url); const handleFromPage = getChannelHandleFromUrl(location.href); const fallbackLabel = handleFromUrl || handleFromPage || ""; const finalLabel = label || fallbackLabel; const key = getChannelKeyFromData(finalLabel, url || handleFromPage); return { label: finalLabel, url: url || handleFromPage, key }; } // Find a video element by stored id. function findVideoById(id) { const escaped = cssEscape(id); return document.querySelector(`[data-${DATA_PREFIX}-id="${escaped}"]`); } // Safe attribute selector escaping. function cssEscape(value) { if (window.CSS && CSS.escape) return CSS.escape(value); return String(value).replace(/["\\]/g, "\\$&"); } function ensureHudDot() { let dot = document.getElementById(HUD_DOT_ID); if (!dot) { dot = document.createElement("div"); dot.id = HUD_DOT_ID; dot.setAttribute(`data-${DATA_PREFIX}-active`, "false"); dot.title = "Member-only videos: 0 (0 hidden)"; document.body.appendChild(dot); } let panel = document.getElementById(HUD_PANEL_ID); if (!panel) { panel = document.createElement("div"); panel.id = HUD_PANEL_ID; panel.setAttribute(`data-${DATA_PREFIX}-hidden`, "true"); document.body.appendChild(panel); } if (!dot.dataset.bound) { dot.dataset.bound = "true"; dot.addEventListener("click", event => { event.stopPropagation(); const isHidden = panel.getAttribute(`data-${DATA_PREFIX}-hidden`) === "true"; panel.setAttribute( `data-${DATA_PREFIX}-hidden`, isHidden ? "false" : "true" ); if (isHidden) renderHudPanel(panel); }); panel.addEventListener("click", event => event.stopPropagation()); document.addEventListener("click", event => { if (panel.getAttribute(`data-${DATA_PREFIX}-hidden`) !== "false") return; if (panel.contains(event.target) || dot.contains(event.target)) return; panel.setAttribute(`data-${DATA_PREFIX}-hidden`, "true"); }); } return dot; } function updateHudDot() { const dot = ensureHudDot(); const panel = document.getElementById(HUD_PANEL_ID); const total = sharedIndex.size; const hidden = total; const signature = `${total}:${hidden}`; dot.title = `Member-only videos: ${total} (${hidden} hidden)`; dot.setAttribute( `data-${DATA_PREFIX}-active`, total > 0 ? "true" : "false" ); if ( panel && panel.getAttribute(`data-${DATA_PREFIX}-hidden`) === "false" && (hudDirty || signature !== lastHudSignature) ) { renderHudPanel(panel); hudDirty = false; lastHudSignature = signature; } } function updateSharedIndex(meta, hidden) { if (!meta.stableId) { meta.stableId = meta.url || meta.id || ""; } if (!meta.stableId) return; if (hidden) { const existing = sharedIndex.get(meta.stableId) || {}; sharedIndex.set(meta.stableId, { id: meta.stableId, channelKey: meta.channelKey || existing.channelKey, channelLabel: meta.channelLabel || existing.channelLabel, channelUrl: meta.channelUrl || existing.channelUrl, title: meta.title || existing.title, url: meta.url || existing.url, thumb: meta.thumb || existing.thumb, snapshot: meta.snapshot ? stripHiddenMarkers(meta.snapshot) : existing.snapshot, hidden: true, lastSeen: Date.now() }); } else { sharedIndex.delete(meta.stableId); } schedulePersistSharedIndex(); } function schedulePersistSharedIndex() { if (persistTimer) return; persistTimer = setTimeout(() => { persistTimer = null; persistSharedIndex(); }, 200); } function persistSharedIndex() { if (!chrome?.storage?.local || contextInvalidated) return; const items = Array.from(sharedIndex.values()); items.sort((a, b) => (b.lastSeen || 0) - (a.lastSeen || 0)); chrome.storage.local.get({ maxArchive: DEFAULT_MAX_ARCHIVE }, data => { const maxArchive = Number(data.maxArchive); const max = Number.isFinite(maxArchive) ? maxArchive : DEFAULT_MAX_ARCHIVE; if (!chrome?.storage?.local || contextInvalidated) return; if (max < 0) { safeStorageSet({ [STORAGE_KEY]: items }); return; } safeStorageSet({ [STORAGE_KEY]: items.slice(0, max) }); }); } function loadStoredIndex() { if (!chrome?.storage?.local) return; chrome.storage.local.get({ [STORAGE_KEY]: [] }, data => { sharedIndex.clear(); data[STORAGE_KEY].forEach(item => sharedIndex.set(item.id, item)); updateHudDot(); }); } function renderHudPanel(panel) { panel.innerHTML = ""; const title = document.createElement("div"); title.className = `${DATA_PREFIX}-hud-title`; title.textContent = "Hidden member-only videos"; panel.appendChild(title); const body = document.createElement("div"); body.className = `${DATA_PREFIX}-hud-body`; panel.appendChild(body); const actions = document.createElement("div"); actions.className = `${DATA_PREFIX}-hud-actions`; const clearBtn = document.createElement("button"); clearBtn.className = `${DATA_PREFIX}-hud-clear`; clearBtn.textContent = "Clear archive"; clearBtn.addEventListener("click", () => clearArchive()); actions.appendChild(clearBtn); body.appendChild(actions); const groups = new Map(); for (const meta of sharedIndex.values()) { if (!meta.hidden) continue; const key = meta.channelKey || getChannelKeyFromData(meta.channelLabel, meta.channelUrl) || "(unknown)"; const list = groups.get(key) || []; list.push(meta); groups.set(key, list); } if (groups.size === 0) { const empty = document.createElement("div"); empty.textContent = "No hidden member-only videos."; body.appendChild(empty); return; } for (const [creator, items] of groups.entries()) { const details = document.createElement("details"); details.className = `${DATA_PREFIX}-channel`; details.open = false; const summary = document.createElement("summary"); summary.className = `${DATA_PREFIX}-channel-header`; const label = items[0]?.channelLabel && items[0].channelLabel !== "(no channel)" ? items[0].channelLabel : creator; summary.textContent = `${label} (${items.length})`; details.appendChild(summary); const list = document.createElement("div"); list.className = `${DATA_PREFIX}-video-list`; items.forEach(meta => { if (meta.snapshot) { const snap = document.createElement("div"); snap.className = `${DATA_PREFIX}-snapshot`; snap.appendChild(sanitizeSnapshotToFragment(meta.snapshot)); forceStripDataMarkers(snap); list.appendChild(snap); return; } const item = document.createElement("div"); item.className = `${DATA_PREFIX}-hud-item`; const img = document.createElement("img"); img.className = `${DATA_PREFIX}-hud-thumb`; img.alt = meta.title || "thumbnail"; if (meta.thumb) img.src = meta.thumb; item.appendChild(img); const link = document.createElement("a"); link.className = `${DATA_PREFIX}-hud-link`; link.textContent = meta.title || meta.url || "(no title)"; link.href = meta.url || "#"; link.target = "_blank"; link.rel = "noopener noreferrer"; item.appendChild(link); list.appendChild(item); }); body.appendChild(details); details.appendChild(list); } } function clearArchive() { sharedIndex.clear(); safeStorageSet({ [STORAGE_KEY]: [] }, () => updateHudDot()); } function safeStorageSet(payload, callback) { try { chrome.storage.local.set(payload, callback); } catch (error) { contextInvalidated = true; } } /* ---------------- initial + observer ---------------- */ process(); // Watch for new items so member-only tags are applied as they load. const observer = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (node instanceof HTMLElement) { process(node); } } } }); observer.observe(document.body, { childList: true, subtree: true });