NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==UserScript==
// @name YouTube Channel Tools
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Conteo de dislikes (read-only via API publica de Return YouTube Dislike) + resaltado de canales favoritos / a evitar en feed, busqueda, sidebar, watch y Shorts. Auto-like / auto-dislike opcional (tambien en Shorts) con guardarrailes. Sin envío de votos.
// @match https://www.youtube.com/*
// @match https://m.youtube.com/*
// @author g31w0fw0rld
// @license MIT
// @downloadURL https://github.com/g31w0fw0rld/youtube-channel-tools/raw/main/youtube-channel-tools.user.js
// @updateURL https://github.com/g31w0fw0rld/youtube-channel-tools/raw/main/youtube-channel-tools.user.js
// @connect returnyoutubedislikeapi.com
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @run-at document-start
// ==/UserScript==
(function () {
"use strict";
const SCRIPT_VERSION = "1.0.0";
const pageWindow = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
const STORAGE_KEY = "yct-state";
const log = (...a) => console.log("[YT Channel Tools]", ...a);
// ---------- TRUSTED TYPES SHIM ----------
// YouTube enforces Trusted Types CSP, so raw innerHTML assignment fails.
// Try to register a policy; if denied, fall back to DOMParser-based hydration.
let ttPolicy = null;
try {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
ttPolicy = window.trustedTypes.createPolicy("yct", { createHTML: s => s });
}
} catch (e) { log("TT policy denied, usando DOMParser fallback:", e.message); }
function setHTML(el, html) {
if (ttPolicy) {
el.innerHTML = ttPolicy.createHTML(html);
return;
}
const doc = new DOMParser().parseFromString(`<!doctype html><body>${html}</body>`, "text/html");
el.replaceChildren(...Array.from(doc.body.childNodes).map(n => document.importNode(n, true)));
}
// ---------- STATE ----------
const state = {
favorites: {},
avoid: {},
settings: {
hideAvoid: false,
showWatchBanner: true,
autoActEnabled: false,
autoActDryRun: false,
autoActMinWatchSec: 10,
autoActDelayMinSec: 3,
autoActDelayMaxSec: 15,
autoActRateLimitPerHour: 5,
showFab: true,
},
};
function loadState() {
try {
const raw = GM_getValue(STORAGE_KEY, null);
if (!raw) return;
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
Object.assign(state.favorites, parsed.favorites || {});
Object.assign(state.avoid, parsed.avoid || {});
Object.assign(state.settings, parsed.settings || {});
} catch (e) { log("loadState error", e); }
}
function saveState() {
try { GM_setValue(STORAGE_KEY, JSON.stringify(state)); }
catch (e) { log("saveState error", e); }
}
function listFor(kind) { return kind === "fav" ? state.favorites : state.avoid; }
function inList(ucId, handle) {
if (ucId && state.favorites[ucId]) return "fav";
if (ucId && state.avoid[ucId]) return "avoid";
if (handle) {
for (const e of Object.values(state.favorites)) if (e.handle === handle) return "fav";
for (const e of Object.values(state.avoid)) if (e.handle === handle) return "avoid";
}
return null;
}
function channelKey(channel) {
return channel?.ucId || channel?.handle || null;
}
function addChannel(kind, channel) {
const key = channelKey(channel);
if (!key) return false;
const other = kind === "fav" ? state.avoid : state.favorites;
delete other[key];
// Also clean any stale entry with the alternate key (handle vs ucId migration)
if (channel.ucId && channel.handle) {
delete other[channel.handle];
delete listFor(kind)[channel.handle];
}
listFor(kind)[key] = {
ucId: channel.ucId || null,
name: channel.name || null,
handle: channel.handle || null,
addedAt: Date.now(),
};
saveState();
return true;
}
function removeChannel(kind, key) {
if (!listFor(kind)[key]) return false;
delete listFor(kind)[key];
saveState();
return true;
}
// ---------- TOAST ----------
function toast(msg, kind = "info", ms = 3000) {
const wrap = ensureToastWrap();
const el = document.createElement("div");
el.className = `yct-toast yct-toast-${kind}`;
el.textContent = msg;
wrap.appendChild(el);
requestAnimationFrame(() => el.classList.add("yct-toast-in"));
setTimeout(() => {
el.classList.remove("yct-toast-in");
setTimeout(() => el.remove(), 250);
}, ms);
}
function ensureToastWrap() {
let w = document.getElementById("yct-toast-wrap");
if (!w) {
w = document.createElement("div");
w.id = "yct-toast-wrap";
(document.body || document.documentElement).appendChild(w);
}
return w;
}
// ---------- DISLIKE COUNT ----------
const API = "https://returnyoutubedislikeapi.com/votes?videoId=";
const dislikeCache = new Map();
const CACHE_TTL = 5 * 60 * 1000;
const MAX_ERRORS = 3;
let dislikeErrors = 0;
let dislikeKilled = false;
let watchVideoId = null;
function isWatchPage() { return location.pathname.startsWith("/watch"); }
function isShortsPage() { return location.pathname.startsWith("/shorts/"); }
function isVideoPage() { return isWatchPage() || isShortsPage(); }
function getVideoId() {
try {
if (isShortsPage()) {
const m = location.pathname.match(/^\/shorts\/([^/?#]+)/);
return m ? m[1] : null;
}
return new URL(location.href).searchParams.get("v");
} catch { return null; }
}
// Devuelve el primer elemento visible que matchea el selector (para Shorts,
// que precarga reels adyacentes; solo nos interesa el que esta en pantalla).
function findVisible(selector, root = document) {
const els = root.querySelectorAll(selector);
for (const el of els) {
if (el.offsetParent === null) continue;
const rect = el.getBoundingClientRect();
if (rect.height > 0 && rect.bottom > 0 && rect.top < window.innerHeight) return el;
}
return els[0] || null;
}
// Contenedor del Short activo. Usamos el ytd-reel-video-renderer visible si
// existe; si no, caemos al overlay/channel bar visibles. Sirve para acotar
// selectores de boton de like/dislike y de canal.
function getActiveShortsScope() {
return findVisible('ytd-reel-video-renderer[is-active]')
|| findVisible('ytd-reel-video-renderer:not([hidden])')
|| findVisible('ytd-reel-video-renderer')
|| document;
}
function fmtCount(n) {
if (typeof n !== "number" || !isFinite(n) || n < 0) return null;
const trim = s => s.replace(/\.0$/, "");
if (n < 1000) return String(n);
if (n < 1e6) return trim((n / 1e3).toFixed(n < 1e4 ? 1 : 0)) + "K";
if (n < 1e9) return trim((n / 1e6).toFixed(n < 1e7 ? 1 : 0)) + "M";
return trim((n / 1e9).toFixed(1)) + "B";
}
function fetchVotes(videoId) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: API + encodeURIComponent(videoId),
timeout: 8000,
headers: { Accept: "application/json" },
onload: r => {
if (r.status < 200 || r.status >= 300) return reject(new Error("HTTP " + r.status));
try { resolve(JSON.parse(r.responseText)); }
catch (e) { reject(e); }
},
onerror: () => reject(new Error("network")),
ontimeout: () => reject(new Error("timeout")),
});
});
}
async function getVotes(videoId) {
const now = Date.now();
const hit = dislikeCache.get(videoId);
if (hit && now - hit.ts < CACHE_TTL) return hit.count;
const data = await fetchVotes(videoId);
const count = data && typeof data.dislikes === "number" ? data.dislikes : null;
if (count == null) throw new Error("invalid");
dislikeCache.set(videoId, { count, ts: now });
return count;
}
function findDislikeBtn() {
if (isShortsPage()) {
const scope = getActiveShortsScope();
return findVisible('dislike-button-view-model button', scope)
|| findVisible('dislike-button-view-model button');
}
if (isWatchPage()) {
// ESTRICTO: solo botones DENTRO de ytd-watch-metadata. Si no
// estan, devolvemos null y dejamos que el tick reintente. Ningun
// fallback al selector global porque podria agarrar un dislike
// de un Shorts sobrante en el DOM (SPA no siempre lo remueve).
const meta = findVisible('ytd-watch-metadata') || document.querySelector('ytd-watch-metadata');
if (!meta) return null;
return meta.querySelector('dislike-button-view-model button')
|| meta.querySelector('#segmented-dislike-button button')
|| meta.querySelector('button[aria-label*="dislike" i]');
}
return null;
}
function paintDislike(text) {
const btn = findDislikeBtn();
if (!btn) return false;
// Shorts: la etiqueta vive en .ytSpecButtonShapeWithLabelLabel (sibling
// del <button> dentro de un <label>). Reemplazamos el texto "No me gusta"
// por el conteo formateado.
if (isShortsPage()) {
const label = btn.parentElement?.querySelector('.ytSpecButtonShapeWithLabelLabel .ytAttributedStringHost')
|| btn.closest('label')?.querySelector('.ytSpecButtonShapeWithLabelLabel .ytAttributedStringHost');
if (!label) return false;
if (label.dataset.yctApplied === text) return true;
label.textContent = text;
label.dataset.yctApplied = text;
return true;
}
// /watch: estructura distinta — span con la clase del shape system.
let node = btn.querySelector('.yt-spec-button-shape-next__button-text-content');
if (!node) {
node = btn.querySelector('span[data-yct-dislike]');
if (!node) {
node = document.createElement('span');
node.setAttribute('data-yct-dislike', '1');
node.className = 'yt-spec-button-shape-next__button-text-content';
node.style.marginLeft = '6px';
btn.appendChild(node);
}
}
if (node.dataset.yctApplied === text) return true;
node.textContent = text;
node.dataset.yctApplied = text;
return true;
}
async function updateDislike() {
if (dislikeKilled) return;
if (!isVideoPage()) return;
const videoId = getVideoId();
if (!videoId) return;
watchVideoId = videoId;
let count;
try {
count = await getVotes(videoId);
dislikeErrors = 0;
} catch (e) {
dislikeErrors++;
log("dislike fetch error:", e.message, `(${dislikeErrors}/${MAX_ERRORS})`);
if (dislikeErrors >= MAX_ERRORS) {
dislikeKilled = true;
log("dislike kill switch ON");
}
return;
}
if (watchVideoId !== videoId) return;
const text = fmtCount(count);
if (!text) return;
let tries = 0;
const tick = () => {
if (watchVideoId !== videoId || dislikeKilled) return;
if (paintDislike(text)) return;
if (++tries < 30) setTimeout(tick, 250);
};
tick();
// Re-paint diferido. YouTube suele re-renderizar el boton 1–3 s despues
// de la navegacion (al cargar metadata extra), borrando nuestro span.
// Como paintDislike es idempotente (data-yct-applied), estos calls son
// no-ops si el span sigue, y re-pintan si fue borrado.
[600, 1500, 3000, 5000].forEach(ms => {
setTimeout(() => {
if (watchVideoId === videoId && !dislikeKilled) paintDislike(text);
}, ms);
});
}
// ---------- AUTO-ACTIONS (guarded) ----------
const session = {
playedByVideo: new Map(),
lastTickTs: 0,
actedVideos: new Set(),
actionTimestamps: [],
scheduledFor: null,
};
const sleep = ms => new Promise(r => setTimeout(r, ms));
function tickPlayback() {
// En Shorts puede haber varios reels precargados con su propio <video>;
// queremos contar segundos solo del reel visible.
let v;
if (isShortsPage()) {
const scope = getActiveShortsScope();
v = scope.querySelector?.("video.html5-main-video")
|| document.querySelector("video.html5-main-video");
} else {
v = document.querySelector("video.html5-main-video");
}
const now = Date.now();
const last = session.lastTickTs;
session.lastTickTs = now;
if (!v || !last) return;
const vid = getVideoId();
if (!vid) return;
if (v.paused || v.seeking || v.ended) return;
const delta = (now - last) / 1000;
if (delta <= 0 || delta > 5) return;
session.playedByVideo.set(vid, (session.playedByVideo.get(vid) || 0) + delta);
}
function isLoggedIn() {
return !!document.querySelector('#avatar-btn img, ytd-topbar-menu-button-renderer #avatar-btn img');
}
function findLikeBtn() {
if (isShortsPage()) {
const scope = getActiveShortsScope();
return findVisible('like-button-view-model button', scope)
|| findVisible('like-button-view-model button');
}
if (isWatchPage()) {
// ESTRICTO: igual que findDislikeBtn — solo dentro de ytd-watch-metadata
// para no agarrar botones de Shorts sobrantes.
const meta = findVisible('ytd-watch-metadata') || document.querySelector('ytd-watch-metadata');
if (!meta) return null;
return meta.querySelector('like-button-view-model button')
|| meta.querySelector('#segmented-like-button button')
|| meta.querySelector('segmented-like-dislike-button-view-model button');
}
return null;
}
function pruneRateLimit() {
const now = Date.now();
session.actionTimestamps = session.actionTimestamps.filter(ts => now - ts < 3600_000);
}
function diagnoseAutoAct() {
if (!state.settings.autoActEnabled) return "Auto-action: desactivado en Ajustes";
if (!isVideoPage()) return "Auto-action: no estás en /watch ni /shorts";
const videoId = getVideoId();
if (!videoId) return "Auto-action: sin videoId";
const ch = detectCurrentChannel();
if (!ch) return "Auto-action: canal del video no detectado";
const kind = inList(ch.ucId, ch.handle);
if (!kind) return `Auto-action: ${ch.name || ch.handle || ch.ucId} no está en ninguna lista`;
if (!isLoggedIn()) return "Auto-action: sesión no detectada (¿estás logueado?)";
if (session.actedVideos.has(videoId)) return "Auto-action: ya actuó en este video (esta sesión)";
const played = session.playedByVideo.get(videoId) || 0;
const min = state.settings.autoActMinWatchSec;
const dMax = state.settings.autoActDelayMaxSec;
if (played < min) return `Auto-action: esperando watch time ${played.toFixed(0)}s / ${min}s`;
if (session.scheduledFor === videoId) return `Auto-action: programada, esperando delay (max ${dMax}s)`;
return `Auto-action: lista para ${kind === "fav" ? "like" : "dislike"} en próximos segundos`;
}
async function maybeAutoAct() {
if (!state.settings.autoActEnabled) return;
if (!isVideoPage()) return;
const videoId = getVideoId();
if (!videoId) return;
if (session.actedVideos.has(videoId)) return;
if (session.scheduledFor === videoId) return;
const ch = detectCurrentChannel();
if (!ch) return;
const kind = inList(ch.ucId, ch.handle);
if (!kind) return;
if (!isLoggedIn()) { log("auto-act skip: no sesion"); return; }
session.scheduledFor = videoId;
const minSec = Math.max(1, state.settings.autoActMinWatchSec);
while ((session.playedByVideo.get(videoId) || 0) < minSec) {
if (getVideoId() !== videoId) { session.scheduledFor = null; return; }
await sleep(1000);
}
const dMin = Math.max(0, state.settings.autoActDelayMinSec);
const dMax = Math.max(dMin, state.settings.autoActDelayMaxSec);
const delayMs = (dMin + Math.random() * (dMax - dMin)) * 1000;
await sleep(delayMs);
if (getVideoId() !== videoId) { session.scheduledFor = null; return; }
pruneRateLimit();
if (session.actionTimestamps.length >= state.settings.autoActRateLimitPerHour) {
toast("Auto-action saltada: limite por hora alcanzado", "error");
session.scheduledFor = null;
session.actedVideos.add(videoId);
return;
}
const target = kind === "fav" ? findLikeBtn() : findDislikeBtn();
const opposite = kind === "fav" ? findDislikeBtn() : findLikeBtn();
session.scheduledFor = null;
if (!target) { log("auto-act skip: boton no encontrado"); return; }
if (target.getAttribute("aria-pressed") === "true") {
log("auto-act skip: ya pulsado");
session.actedVideos.add(videoId);
return;
}
if (opposite?.getAttribute("aria-pressed") === "true") {
log("auto-act skip: voto opuesto manual presente");
session.actedVideos.add(videoId);
return;
}
if (target.getAttribute("aria-disabled") === "true" || target.disabled) {
log("auto-act skip: boton deshabilitado");
session.actedVideos.add(videoId);
return;
}
if (state.settings.autoActDryRun) {
toast(`[Dry-run] ${kind === "fav" ? "Like" : "Dislike"} simulado · ${ch.name || ch.handle || ch.ucId}`);
session.actedVideos.add(videoId);
return;
}
target.click();
session.actionTimestamps.push(Date.now());
session.actedVideos.add(videoId);
toast(`Auto-${kind === "fav" ? "like" : "dislike"} · ${ch.name || ch.handle || ch.ucId}`);
}
// ---------- CHANNEL DETECTION ----------
function parseChannelHref(href) {
if (!href) return { ucId: null, handle: null };
const u = href.startsWith("/") ? "https://www.youtube.com" + href : href;
try {
const url = new URL(u);
const m1 = url.pathname.match(/^\/channel\/(UC[\w-]{20,})/);
if (m1) return { ucId: m1[1], handle: null };
const m2 = url.pathname.match(/^\/(@[\w.\-]+)/);
if (m2) return { ucId: null, handle: m2[1] };
} catch {}
return { ucId: null, handle: null };
}
function scrapeChannelIdFromScripts() {
// Last resort: ytInitialPlayerResponse / ytInitialData are inlined in <script> tags.
const scripts = document.querySelectorAll("script:not([src])");
for (const s of scripts) {
const t = s.textContent;
if (!t || t.length < 100) continue;
if (!t.includes("ytInitialPlayerResponse") && !t.includes("ytInitialData")) continue;
const m = t.match(/"channelId":"(UC[\w-]+)"/) || t.match(/"externalChannelId":"(UC[\w-]+)"/) || t.match(/"externalId":"(UC[\w-]+)"/);
if (m) return m[1];
}
return null;
}
function detectCurrentChannel() {
// ---------- /shorts/<id> ----------
// Cada Short tiene un yt-reel-channel-bar-view-model con un <a href="/@handle">
// dentro del reel activo. La UC normalmente no esta visible en el bar; el
// matching contra la lista usa el handle (suficiente porque los handles son
// unicos en YouTube y storage los acepta como key).
if (isShortsPage()) {
const scope = getActiveShortsScope();
const a = findVisible('yt-reel-channel-bar-view-model a[href^="/@"], yt-reel-channel-bar-view-model a[href^="/channel/"]', scope)
|| findVisible('yt-reel-channel-bar-view-model a[href^="/@"], yt-reel-channel-bar-view-model a[href^="/channel/"]');
const href = a?.getAttribute("href");
const parsed = parseChannelHref(href);
const name = a?.textContent?.trim() || null;
if (parsed.ucId || parsed.handle) {
return { ucId: parsed.ucId, name, handle: parsed.handle, videoId: getVideoId() };
}
return null;
}
// ---------- /watch ----------
if (isWatchPage()) {
const currentVideoId = getVideoId();
const a = document.querySelector('ytd-watch-metadata ytd-channel-name a, #upload-info ytd-channel-name a, ytd-video-owner-renderer a.yt-simple-endpoint');
const href = a?.getAttribute("href");
const parsed = parseChannelHref(href);
const handle = parsed.handle;
const name = a?.textContent?.trim() || null;
// UC sources (in order, only if trustworthy for the CURRENT video):
// 1. IPR (only when its videoId matches the URL)
// 2. <a href="/channel/UCxxx"> from the channel-name link (rare on /watch but unambiguous)
// We deliberately skip <meta itemprop="channelId"> and inline-script scrape on /watch
// because they can lag behind during SPA navigation and return the previous video's UC.
const ipr = pageWindow.ytInitialPlayerResponse;
const iprFresh = ipr?.videoDetails?.videoId === currentVideoId;
let ucId = null, nameFromIPR = null;
if (iprFresh) {
ucId = ipr.videoDetails.channelId;
nameFromIPR = ipr.videoDetails.author;
} else if (parsed.ucId) {
ucId = parsed.ucId;
}
if (ucId || handle) {
return { ucId: ucId || null, name: name || nameFromIPR || null, handle, videoId: currentVideoId, _iprFresh: iprFresh };
}
return null;
}
// ---------- /channel/UCxxx ----------
const cm = location.pathname.match(/^\/channel\/(UC[\w-]{20,})/);
if (cm) {
const ucId = cm[1];
const name = document.querySelector('yt-formatted-string.ytd-channel-name, #channel-name #text, .page-header-view-model-wiz__page-header-headline-info, ytd-channel-name yt-formatted-string')?.textContent?.trim() || null;
const handleEl = document.querySelector("#channel-handle, yt-formatted-string.page-header-view-model-wiz__page-header-byline-line");
const handleText = handleEl?.textContent?.trim() || "";
const handleMatch = handleText.match(/^@[\w.\-]+/);
return { ucId, name, handle: handleMatch?.[0] || null };
}
// ---------- /@handle ----------
if (location.pathname.startsWith("/@")) {
const handle = location.pathname.split("/")[1];
const canonical = document.querySelector('link[rel="canonical"]');
const ucId = parseChannelHref(canonical?.href).ucId
|| scrapeChannelIdFromScripts();
const name = document.querySelector('yt-formatted-string.ytd-channel-name, #channel-name #text, .page-header-view-model-wiz__page-header-headline-info, ytd-channel-name yt-formatted-string')?.textContent?.trim() || null;
return { ucId: ucId || null, name, handle };
}
return null;
}
// ---------- CARD DECORATION ----------
const CARD_SELECTORS = [
"ytd-rich-item-renderer",
"ytd-video-renderer",
"ytd-compact-video-renderer",
"ytd-grid-video-renderer",
"ytd-rich-grid-media",
"ytd-playlist-video-renderer",
"ytd-reel-item-renderer",
].join(",");
function decorateCard(card) {
if (!card || card.dataset.yctChecked === "1") return;
const channelLinks = card.querySelectorAll('a[href^="/channel/"], a[href^="/@"], ytd-channel-name a');
let ucId = null, handle = null;
for (const a of channelLinks) {
const p = parseChannelHref(a.getAttribute("href"));
if (p.ucId && !ucId) ucId = p.ucId;
if (p.handle && !handle) handle = p.handle;
if (ucId) break;
}
const kind = inList(ucId, handle);
card.dataset.yctChecked = "1";
if (!kind) return;
card.classList.add("yct-card", kind === "fav" ? "yct-fav" : "yct-avoid");
if (kind === "avoid" && state.settings.hideAvoid) {
card.classList.add("yct-hidden");
}
// re-check on DOM mutation by clearing flag if channel link disappears later? Keep simple for v1.
}
function decorateAll(root = document) {
root.querySelectorAll(CARD_SELECTORS).forEach(decorateCard);
}
function refreshAllCards() {
document.querySelectorAll(CARD_SELECTORS).forEach(c => { delete c.dataset.yctChecked; c.classList.remove("yct-card", "yct-fav", "yct-avoid", "yct-hidden"); });
decorateAll();
}
let cardObserver = null;
function startCardObserver() {
if (cardObserver) return;
cardObserver = new MutationObserver(muts => {
for (const m of muts) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.matches?.(CARD_SELECTORS)) decorateCard(node);
else node.querySelectorAll?.(CARD_SELECTORS).forEach(decorateCard);
}
}
});
cardObserver.observe(document.body || document.documentElement, { childList: true, subtree: true });
}
// ---------- WATCH BANNER ----------
function renderWatchBanner(retries = 0) {
const onWatch = location.pathname.startsWith("/watch");
const videoId = getVideoId();
const existing = document.getElementById("yct-watch-banner");
if (!onWatch || !state.settings.showWatchBanner) {
existing?.remove();
return;
}
// If the existing banner was rendered for a different video, drop it before re-evaluating.
if (existing && existing.dataset.videoId !== videoId) {
existing.remove();
}
const ch = detectCurrentChannel();
if (!ch) {
if (retries < 30) setTimeout(() => renderWatchBanner(retries + 1), 300);
return;
}
const kind = inList(ch.ucId, ch.handle);
const newKey = `${kind || "none"}|${channelKey(ch) || ""}`;
const target = document.querySelector("ytd-watch-metadata #title, #title.ytd-watch-metadata, #above-the-fold #title");
if (!kind) {
document.getElementById("yct-watch-banner")?.remove();
} else {
if (!target) {
if (retries < 30) setTimeout(() => renderWatchBanner(retries + 1), 300);
return;
}
const current = document.getElementById("yct-watch-banner");
if (!current || current.dataset.bannerKey !== newKey) {
const banner = current || document.createElement("div");
banner.id = "yct-watch-banner";
banner.dataset.videoId = videoId;
banner.dataset.bannerKey = newKey;
banner.className = `yct-banner yct-banner-${kind}`;
banner.textContent = kind === "fav"
? `Canal en tu lista de favoritos — recordá darle like si te gustó.`
: `Canal en tu lista de "evitar".`;
if (!current) target.parentElement?.insertBefore(banner, target);
}
}
// If IPR was not fresh, give YouTube a moment to update it and re-evaluate.
if (ch._iprFresh === false && retries < 10) {
setTimeout(() => renderWatchBanner(retries + 1), 500);
}
}
// ---------- CHANNEL ACTION PILLS (shared) ----------
function buildChannelPills(ch, onChange) {
const current = inList(ch.ucId, ch.handle);
const key = channelKey(ch);
const wrap = document.createElement("div");
const mkBtn = (label, kind, active) => {
const b = document.createElement("button");
b.type = "button";
b.className = `yct-pill ${active ? "yct-pill-active-" + kind : ""}`;
b.textContent = label;
b.addEventListener("click", e => {
e.stopPropagation();
e.preventDefault();
if (current === kind) {
// Remove using whichever key is in storage (UC or handle)
if (ch.ucId) removeChannel(kind, ch.ucId);
if (ch.handle) removeChannel(kind, ch.handle);
toast(`Quitado de ${kind === "fav" ? "favoritos" : "evitar"}`);
} else {
addChannel(kind, ch);
toast(`Agregado a ${kind === "fav" ? "favoritos" : "evitar"}: ${ch.name || ch.handle || ch.ucId}`);
}
onChange?.();
});
return b;
};
wrap.appendChild(mkBtn(current === "fav" ? "♥ Favorito" : "♡ Favorito", "fav", current === "fav"));
wrap.appendChild(mkBtn(current === "avoid" ? "✕ Evitar" : "○ Evitar", "avoid", current === "avoid"));
return wrap;
}
function refreshAllUi() {
renderChannelPageButton();
renderWatchActions();
renderWatchBanner();
renderShortsActions();
refreshAllCards();
maybeAutoAct();
}
// ---------- CHANNEL PAGE ACTION BUTTON ----------
function renderChannelPageButton(retries = 0) {
const onChannelPage = location.pathname.startsWith("/channel/") || location.pathname.startsWith("/@");
const wrap = document.getElementById("yct-channel-actions");
if (!onChannelPage) { wrap?.remove(); return; }
const ch = detectCurrentChannel();
// Sequential queries so the most specific host wins (querySelector with a list returns
// the element earliest in document order, not the most specific selector).
const host = document.querySelector("yt-flexible-actions-view-model")
|| document.querySelector("ytd-c4-tabbed-header-renderer #inner-header-container")
|| document.querySelector("#channel-header #inner-header-container")
|| document.querySelector("ytd-tabbed-page-header yt-page-header-renderer")
|| document.querySelector("page-header-view-model");
if (!ch || !host) {
if (retries < 30) setTimeout(() => renderChannelPageButton(retries + 1), 300);
return;
}
const w = wrap || document.createElement("div");
w.id = "yct-channel-actions";
// Match YouTube's flexible-actions layout when the host is yt-flexible-actions-view-model
if (host.matches?.("yt-flexible-actions-view-model")) {
w.className = "ytFlexibleActionsViewModelAction";
}
w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
if (!wrap) host.appendChild(w);
}
// ---------- WATCH PAGE ACTION BUTTONS ----------
function renderWatchActions(retries = 0) {
const onWatch = location.pathname.startsWith("/watch");
const videoId = getVideoId();
const existing = document.getElementById("yct-watch-actions");
if (!onWatch) { existing?.remove(); return; }
// Drop stale wrap (from a previous video) before re-evaluating.
if (existing && existing.dataset.videoId !== videoId) {
existing.remove();
}
const ch = detectCurrentChannel();
const owner = document.querySelector("ytd-watch-metadata #owner");
if (!ch || !owner) {
if (retries < 30) setTimeout(() => renderWatchActions(retries + 1), 300);
return;
}
// Render with whatever data we have; identify what was rendered with a key so we
// know whether to repaint when IPR upgrades from handle-only to UC.
const renderKey = `${channelKey(ch) || ""}|${inList(ch.ucId, ch.handle) || "none"}`;
const current = document.getElementById("yct-watch-actions");
if (!current || current.dataset.renderKey !== renderKey) {
const w = current || document.createElement("div");
w.id = "yct-watch-actions";
w.dataset.videoId = videoId;
w.dataset.renderKey = renderKey;
w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
if (!current) owner.appendChild(w);
}
// If IPR was not fresh, give YouTube a moment to update it and re-evaluate.
if (ch._iprFresh === false && retries < 10) {
setTimeout(() => renderWatchActions(retries + 1), 500);
}
}
// ---------- SHORTS ACTION PILLS + STATUS BADGE ----------
// En Shorts no hay un area "owner" estable como en /watch. Inyectamos los
// pills (♥/✕) dentro del yt-reel-channel-bar-view-model del reel activo, y
// marcamos el bar con clase yct-shorts-fav / yct-shorts-avoid si el canal
// esta en alguna lista (sirve como banner visual sin ocupar espacio extra).
function renderShortsActions(retries = 0) {
if (!isShortsPage()) {
document.querySelectorAll("#yct-shorts-actions").forEach(el => el.remove());
document.querySelectorAll(".yct-shorts-fav, .yct-shorts-avoid").forEach(el => {
el.classList.remove("yct-shorts-fav", "yct-shorts-avoid");
});
return;
}
const videoId = getVideoId();
const scope = getActiveShortsScope();
const bar = findVisible("yt-reel-channel-bar-view-model", scope)
|| findVisible("yt-reel-channel-bar-view-model");
const ch = detectCurrentChannel();
if (!bar || !ch) {
if (retries < 30) setTimeout(() => renderShortsActions(retries + 1), 300);
return;
}
// Limpiar status visual de bars antiguos antes de pintar el activo.
document.querySelectorAll(".yct-shorts-fav, .yct-shorts-avoid").forEach(el => {
if (el !== bar) el.classList.remove("yct-shorts-fav", "yct-shorts-avoid");
});
const kind = inList(ch.ucId, ch.handle);
bar.classList.toggle("yct-shorts-fav", kind === "fav");
bar.classList.toggle("yct-shorts-avoid", kind === "avoid");
const renderKey = `${channelKey(ch) || ""}|${kind || "none"}|${videoId}`;
const existing = bar.querySelector("#yct-shorts-actions");
if (existing && existing.dataset.renderKey === renderKey) return;
existing?.remove();
const w = document.createElement("div");
w.id = "yct-shorts-actions";
w.dataset.renderKey = renderKey;
w.replaceChildren(...buildChannelPills(ch, refreshAllUi).children);
bar.appendChild(w);
}
// ---------- MANAGER MODAL ----------
function openManager() {
document.getElementById("yct-modal")?.remove();
const overlay = document.createElement("div");
overlay.id = "yct-modal";
setHTML(overlay, `
<div class="yct-modal-card">
<div class="yct-modal-head">
<h2>YouTube Channel Tools</h2>
<button type="button" class="yct-x" aria-label="Cerrar">×</button>
</div>
<div class="yct-tabs">
<button data-tab="fav" class="yct-tab yct-tab-active">Favoritos (<span data-count="fav">0</span>)</button>
<button data-tab="avoid" class="yct-tab">Evitar (<span data-count="avoid">0</span>)</button>
<button data-tab="settings" class="yct-tab">Ajustes</button>
</div>
<div class="yct-tab-body" data-body="fav"></div>
<div class="yct-tab-body yct-hidden" data-body="avoid"></div>
<div class="yct-tab-body yct-hidden" data-body="settings"></div>
<div class="yct-modal-foot">
<button type="button" class="yct-btn" data-act="export">Exportar</button>
<button type="button" class="yct-btn" data-act="import">Importar</button>
</div>
</div>
`);
document.body.appendChild(overlay);
overlay.addEventListener("click", e => { if (e.target === overlay) overlay.remove(); });
overlay.querySelector(".yct-x").addEventListener("click", () => overlay.remove());
overlay.querySelectorAll(".yct-tab").forEach(t => {
t.addEventListener("click", () => {
overlay.querySelectorAll(".yct-tab").forEach(x => x.classList.remove("yct-tab-active"));
t.classList.add("yct-tab-active");
const which = t.dataset.tab;
overlay.querySelectorAll(".yct-tab-body").forEach(b => b.classList.toggle("yct-hidden", b.dataset.body !== which));
});
});
overlay.querySelector('[data-act="export"]').addEventListener("click", () => {
const blob = new Blob([JSON.stringify(state, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = "yct-export.json"; a.click();
setTimeout(() => URL.revokeObjectURL(url), 5000);
});
overlay.querySelector('[data-act="import"]').addEventListener("click", () => {
const input = document.createElement("input");
input.type = "file"; input.accept = "application/json";
input.addEventListener("change", () => {
const f = input.files?.[0]; if (!f) return;
const r = new FileReader();
r.onload = () => {
try {
const parsed = JSON.parse(r.result);
if (parsed.favorites) state.favorites = parsed.favorites;
if (parsed.avoid) state.avoid = parsed.avoid;
if (parsed.settings) Object.assign(state.settings, parsed.settings);
saveState();
renderManagerLists(overlay);
refreshAllUi();
toast("Importado");
} catch (e) { toast("Archivo invalido", "error"); }
};
r.readAsText(f);
});
input.click();
});
renderManagerLists(overlay);
renderManagerSettings(overlay);
}
function renderManagerLists(overlay) {
for (const kind of ["fav", "avoid"]) {
const body = overlay.querySelector(`[data-body="${kind}"]`);
const list = listFor(kind);
const entries = Object.values(list).sort((a, b) => (a.name || a.handle || a.ucId).localeCompare(b.name || b.handle || b.ucId));
overlay.querySelector(`[data-count="${kind}"]`).textContent = entries.length;
if (!entries.length) setHTML(body, `<p class="yct-empty">Sin canales. Agregalos desde la pagina del canal.</p>`);
else body.replaceChildren();
for (const e of entries) {
const row = document.createElement("div");
row.className = "yct-row";
const key = e.ucId || e.handle;
setHTML(row, `
<div class="yct-row-info">
<strong>${escapeHtml(e.name || e.handle || e.ucId || "?")}</strong>
<small>${escapeHtml(e.handle || "")} ${escapeHtml(e.ucId || "")}</small>
</div>
<button type="button" class="yct-btn yct-btn-danger">Quitar</button>
`);
row.querySelector("button").addEventListener("click", () => {
removeChannel(kind, key);
renderManagerLists(overlay);
refreshAllUi();
toast(`Quitado: ${e.name || key}`);
});
body.appendChild(row);
}
}
}
function renderManagerSettings(overlay) {
const body = overlay.querySelector('[data-body="settings"]');
setHTML(body, `
<h3 class="yct-section">Visual</h3>
<label class="yct-opt"><input type="checkbox" data-set="showFab"> Mostrar boton flotante para abrir el gestor</label>
<label class="yct-opt"><input type="checkbox" data-set="hideAvoid"> Ocultar videos de canales en la lista "evitar"</label>
<label class="yct-opt"><input type="checkbox" data-set="showWatchBanner"> Mostrar aviso en pagina del video</label>
<h3 class="yct-section">Auto-acciones (riesgo)</h3>
<div class="yct-warn">
<strong>⚠ Lee antes de activar.</strong>
Habilitar esto hace que el script clickee like / dislike por vos en videos de las listas. YouTube prohibe interacciones automatizadas en su ToS, y aunque los guardarraíles bajan el riesgo, no lo eliminan: el peor caso reportado es <em>shadow-throttle</em> (tus likes no cuentan); el peor teorico es suspension de la cuenta. Probalo primero en modo simulado.
</div>
<label class="yct-opt"><input type="checkbox" data-set="autoActEnabled"> Habilitar auto-like / auto-dislike por canal</label>
<label class="yct-opt"><input type="checkbox" data-set="autoActDryRun"> Modo simulado (loguea y muestra toast, no clickea)</label>
<label class="yct-opt yct-opt-num">
Limite por hora
<input type="number" min="1" max="60" data-set-num="autoActRateLimitPerHour">
</label>
<label class="yct-opt yct-opt-num">
Tiempo minimo de reproduccion (s)
<input type="number" min="1" max="600" data-set-num="autoActMinWatchSec">
</label>
<label class="yct-opt yct-opt-num">
Delay aleatorio min (s)
<input type="number" min="0" max="120" data-set-num="autoActDelayMinSec">
</label>
<label class="yct-opt yct-opt-num">
Delay aleatorio max (s)
<input type="number" min="0" max="600" data-set-num="autoActDelayMaxSec">
</label>
<p class="yct-hint">
Guardarraíles activos: requiere sesion iniciada · skip si ya esta pulsado · skip si el voto opuesto fue puesto por vos · skip si el boton esta deshabilitado · una sola vez por video por sesion · solo cuenta tiempo de reproduccion real (no pausado, no seek).
</p>
<h3 class="yct-section">Estado actual</h3>
<div class="yct-diag" data-diag>—</div>
<button type="button" class="yct-btn" data-act="diag-refresh">Refrescar diagnóstico</button>
<p class="yct-hint">Versión ${SCRIPT_VERSION}. Conteo de dislikes vía API publica de Return YouTube Dislike. Sin envío de votos hacia esa API.</p>
`);
const diagEl = body.querySelector("[data-diag]");
const updateDiag = () => { if (diagEl) diagEl.textContent = diagnoseAutoAct(); };
updateDiag();
body.querySelector('[data-act="diag-refresh"]')?.addEventListener("click", updateDiag);
body.querySelectorAll("input[data-set]").forEach(cb => {
const key = cb.dataset.set;
cb.checked = !!state.settings[key];
cb.addEventListener("change", () => {
state.settings[key] = cb.checked;
saveState();
refreshAllUi();
if (key === "showFab") injectFab();
if (key === "autoActEnabled") {
toast(diagnoseAutoAct(), "info", 5000);
updateDiag();
}
});
});
body.querySelectorAll("input[data-set-num]").forEach(inp => {
const key = inp.dataset.setNum;
inp.value = state.settings[key];
inp.addEventListener("change", () => {
const n = parseInt(inp.value, 10);
if (!isFinite(n) || n < 0) { inp.value = state.settings[key]; return; }
state.settings[key] = n;
saveState();
});
});
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({ "&": "&", "<": "<", ">": ">", "\"": """, "'": "'" }[c]));
}
// ---------- STYLES ----------
function injectStyles() {
GM_addStyle(`
#yct-fab { position: fixed; bottom: 20px; left: 20px; width: 36px; height: 36px; border-radius: 50%; border: 1px solid #444; background: rgba(34,34,34,.85); color: #fff; cursor: pointer; display: flex; align-items: center; justify-content: center; z-index: 2147483645; box-shadow: 0 2px 8px rgba(0,0,0,.4); opacity: .55; transition: opacity .15s, transform .15s; padding: 0; }
#yct-fab:hover { opacity: 1; transform: scale(1.08); }
#yct-toast-wrap { position: fixed; right: 20px; bottom: 20px; z-index: 2147483647; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.yct-toast { background: #222; color: #fff; padding: 10px 14px; border-radius: 6px; font: 13px sans-serif; box-shadow: 0 4px 12px rgba(0,0,0,.3); opacity: 0; transform: translateY(8px); transition: opacity .2s, transform .2s; pointer-events: auto; max-width: 360px; }
.yct-toast-in { opacity: 1; transform: translateY(0); }
.yct-toast-error { background: #b3261e; }
.yct-card.yct-fav { outline: 2px solid #4caf50; outline-offset: -2px; border-radius: 12px; }
.yct-card.yct-avoid { outline: 2px solid #c62828; outline-offset: -2px; border-radius: 12px; opacity: .7; }
.yct-card.yct-fav::before, .yct-card.yct-avoid::before {
content: ""; position: absolute; top: 6px; left: 6px; width: 22px; height: 22px; border-radius: 50%; z-index: 5;
background-size: 14px 14px; background-position: center; background-repeat: no-repeat;
}
.yct-card.yct-fav::before { background-color: #4caf50; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M12 21s-7-4.5-9.5-9.1C.7 8.6 2.4 5 6 5c2 0 3.3 1 4 2 .7-1 2-2 4-2 3.6 0 5.3 3.6 3.5 6.9C19 16.5 12 21 12 21z'/></svg>"); }
.yct-card.yct-avoid::before { background-color: #c62828; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='white'><path d='M19 6.4L17.6 5 12 10.6 6.4 5 5 6.4 10.6 12 5 17.6 6.4 19 12 13.4 17.6 19 19 17.6 13.4 12z'/></svg>"); }
.yct-card { position: relative; }
.yct-hidden { display: none !important; }
.yct-banner { padding: 10px 14px; border-radius: 8px; font: 500 13px/1.4 sans-serif !important; margin-bottom: 10px; color: #fff !important; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
.yct-banner-fav { background: #2e7d32 !important; border-left: 4px solid #81c784; }
.yct-banner-avoid { background: #c62828 !important; border-left: 4px solid #ef5350; }
#yct-channel-actions { display: inline-flex; gap: 8px; align-items: center; }
#yct-watch-actions { display: inline-flex; gap: 8px; margin-left: 12px; align-items: center; }
.yct-pill { background: var(--yt-spec-badge-chip-background, #303030); color: var(--yt-spec-text-primary, #fff); border: 1px solid transparent; border-radius: 18px; padding: 6px 14px; font: 13px sans-serif; cursor: pointer; }
.yct-pill:hover { background: var(--yt-spec-button-chip-background-hover, #3f3f3f); }
.yct-pill-active-fav { background: #4caf50; color: #fff; border-color: #4caf50; }
.yct-pill-active-avoid { background: #c62828; color: #fff; border-color: #c62828; }
/* Shorts: pills compactos pegados al channel-bar */
#yct-shorts-actions { display: inline-flex; gap: 6px; margin-left: 8px; vertical-align: middle; }
#yct-shorts-actions .yct-pill { padding: 3px 10px; font-size: 11px; border-radius: 14px; }
/* Indicador de favorito / evitar sobre el bar del canal del Short */
yt-reel-channel-bar-view-model.yct-shorts-fav { box-shadow: inset 4px 0 0 #4caf50; padding-left: 6px; border-radius: 4px; }
yt-reel-channel-bar-view-model.yct-shorts-avoid { box-shadow: inset 4px 0 0 #c62828; padding-left: 6px; border-radius: 4px; }
#yct-modal { position: fixed; inset: 0; background: rgba(0,0,0,.55); z-index: 2147483646; display: flex; align-items: center; justify-content: center; }
.yct-modal-card { background: #1f1f1f; color: #fff; border-radius: 12px; width: min(560px, 92vw); max-height: 86vh; display: flex; flex-direction: column; font: 14px sans-serif; }
.yct-modal-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 18px; border-bottom: 1px solid #333; }
.yct-modal-head h2 { margin: 0; font-size: 16px; font-weight: 600; }
.yct-x { background: transparent; border: none; color: #fff; font-size: 22px; cursor: pointer; line-height: 1; }
.yct-tabs { display: flex; gap: 0; border-bottom: 1px solid #333; }
.yct-tab { flex: 1; background: transparent; border: none; color: #bbb; padding: 10px; cursor: pointer; font: inherit; border-bottom: 2px solid transparent; }
.yct-tab-active { color: #fff; border-bottom-color: #4caf50; }
.yct-tab-body { padding: 14px 18px; overflow-y: auto; flex: 1; }
.yct-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #2a2a2a; gap: 12px; }
.yct-row:last-child { border-bottom: none; }
.yct-row-info strong { display: block; }
.yct-row-info small { color: #888; font-size: 11px; word-break: break-all; }
.yct-empty { color: #888; text-align: center; padding: 20px; }
.yct-opt { display: flex; align-items: center; gap: 8px; padding: 6px 0; cursor: pointer; }
.yct-opt-num { justify-content: space-between; }
.yct-opt-num input[type="number"] { width: 80px; background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 4px; padding: 4px 6px; font: inherit; }
.yct-section { margin: 16px 0 4px; font-size: 13px; color: #ccc; text-transform: uppercase; letter-spacing: .5px; }
.yct-warn { background: rgba(198,40,40,.12); border-left: 3px solid #c62828; padding: 10px 12px; border-radius: 4px; font-size: 13px; line-height: 1.4; margin: 8px 0 12px; }
.yct-warn strong { color: #ff8a80; }
.yct-hint { color: #888; font-size: 12px; margin-top: 12px; line-height: 1.4; }
.yct-diag { background: #2a2a2a; border-left: 3px solid #4caf50; padding: 8px 12px; border-radius: 4px; font-size: 13px; margin: 4px 0 8px; font-family: monospace; }
.yct-btn { background: #2a2a2a; color: #fff; border: 1px solid #444; border-radius: 6px; padding: 6px 12px; cursor: pointer; font: inherit; }
.yct-btn:hover { background: #383838; }
.yct-btn-danger { background: transparent; border-color: #c62828; color: #c62828; }
.yct-btn-danger:hover { background: rgba(198,40,40,.15); }
.yct-modal-foot { padding: 12px 18px; border-top: 1px solid #333; display: flex; gap: 8px; justify-content: flex-end; }
`);
}
// ---------- FLOATING BUTTON ----------
function injectFab() {
if (!state.settings.showFab) {
document.getElementById("yct-fab")?.remove();
return;
}
if (document.getElementById("yct-fab")) return;
const fab = document.createElement("button");
fab.id = "yct-fab";
fab.type = "button";
fab.title = "YouTube Channel Tools";
fab.setAttribute("aria-label", "Abrir gestor de canales");
setHTML(fab, `<svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18"><path d="M19.43 13c.04-.33.07-.66.07-1s-.03-.67-.07-1l2.11-1.65a.5.5 0 00.12-.64l-2-3.46a.5.5 0 00-.6-.22l-2.49 1a7.3 7.3 0 00-1.73-1L14.5 2.42a.5.5 0 00-.5-.42h-4a.5.5 0 00-.5.42l-.31 2.61c-.62.25-1.2.59-1.73 1l-2.49-1a.5.5 0 00-.6.22l-2 3.46a.5.5 0 00.12.64L4.6 11c-.04.33-.07.66-.07 1s.03.67.07 1l-2.11 1.65a.5.5 0 00-.12.64l2 3.46c.14.24.43.34.69.22l2.49-1c.53.41 1.11.75 1.73 1l.31 2.61c.05.24.27.42.5.42h4c.23 0 .45-.18.5-.42l.31-2.61c.62-.25 1.2-.59 1.73-1l2.49 1a.5.5 0 00.6-.22l2-3.46a.5.5 0 00-.12-.64L19.43 13zM12 15.5A3.5 3.5 0 1115.5 12 3.5 3.5 0 0112 15.5z"/></svg>`);
fab.addEventListener("click", openManager);
(document.body || document.documentElement).appendChild(fab);
}
// ---------- BOOT ----------
function update() {
updateDislike();
renderWatchBanner();
renderChannelPageButton();
renderWatchActions();
renderShortsActions();
decorateAll();
injectFab();
maybeAutoAct();
}
function boot() {
loadState();
injectStyles();
startCardObserver();
decorateAll();
renderWatchBanner();
renderChannelPageButton();
renderWatchActions();
renderShortsActions();
injectFab();
updateDislike();
setInterval(tickPlayback, 1000);
maybeAutoAct();
window.addEventListener("yt-navigate-finish", update, true);
// Shorts cambia de video por scroll-snap sin disparar yt-navigate-finish
// de forma fiable. Polling ligero del URL para reaccionar al cambio.
let lastUrl = location.href;
setInterval(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
update();
}
}, 500);
try { GM_registerMenuCommand("Abrir gestor de canales", openManager); } catch {}
log("v" + SCRIPT_VERSION + " cargado", { fav: Object.keys(state.favorites).length, avoid: Object.keys(state.avoid).length });
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
})();