AniList Dubs

Labels English dubbed titles on AniList.co, adds dub-only filtering

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         AniList Dubs
// @version      1.5.0
// @description  Labels English dubbed titles on AniList.co, adds dub-only filtering
// @namespace    https://github.com/hamzaharoon1314/AniList-Dubs/
// @copyright    © 2019-2025 MAL-Dubs; Icons and glyphs within this project are TM MAL-Dubs
// @license      GNU AGPLv3; https://github.com/MAL-Dubs/MAL-Dubs/raw/main/LICENSE
// @author       HAMO (AniList support), MAL Dubs
// @supportURL   https://github.com/hamzaharoon1314/AniList-Dubs/issues
// @match        https://anilist.co/*
// @iconURL      https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/images/icon.png
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      githubusercontent.com
// @connect      github.com
// @connect      graphql.anilist.co
// @connect      anilist.co
// @run-at       document-end
// @noframes
// ==/UserScript==

'use strict';

const ANIME_ID_RE     = /\/anime\/(\d+)/;
const DUB_INFO_URL    = 'https://raw.githubusercontent.com/MAL-Dubs/MAL-Dubs/main/data/dubInfo.json';
const GRAPHQL_URL     = 'https://graphql.anilist.co';
const CACHE_TTL_MS    = 600_000; // 10 minutes

// OAuth — implicit grant
const OAUTH_CLIENT_ID = '40465';
const OAUTH_AUTH_URL  = 'https://anilist.co/api/v2/oauth/authorize';

// Authenticated clients get 90 req/min; anonymous get 30 req/min.
// perPage hard cap is 50 for Page queries regardless of auth.
const GQL_PAGE_SIZE   = 50;
const GQL_QUERY = 'query($ids:[Int]){Page(perPage:' + GQL_PAGE_SIZE + '){media(id_in:$ids,type:ANIME){id idMal}}}';

// Filter cycle
const FILTER_OPTS   = ['filter-off', 'only-dubs', 'no-dubs'];
const FILTER_LABELS = ['Show All Anime', 'Only Dubbed', 'Hide Dubbed'];

/** Load a stored token and validate it hasn't expired. */
function loadToken() {
  try {
    const raw = GM_getValue('oauthToken', null);
    if (!raw) return null;
    const t = JSON.parse(raw);
    if (t.expiresAt && Date.now() > t.expiresAt) {
      GM_deleteValue('oauthToken');
      return null;
    }
    return t;
  } catch (_) { return null; }
}

let oauthToken = loadToken(); 

function authHeader() {
  return oauthToken ? 'Bearer ' + oauthToken.accessToken : null;
}

function login() {
  const authURL = OAUTH_AUTH_URL +
    '?client_id=' + OAUTH_CLIENT_ID +
    '&response_type=token';

  const popup = window.open(authURL, 'anilist_oauth',
    'width=600,height=720,left=' + Math.round((screen.width  - 600) / 2) +
    ',top='  + Math.round((screen.height - 720) / 2));

  if (!popup) {
    showAuthToast('⚠️ Popup blocked — allow popups for anilist.co and try again.');
    return;
  }

  let done = false;

  function handleHash() {
    if (done) return;
    let hash;
    try { hash = popup.location.hash; } catch (_) { return; } // still cross-origin

    if (!hash || !hash.includes('access_token')) return;

    done = true;
    clearInterval(poll);
    popup.close();

    const params      = new URLSearchParams(hash.slice(1));
    const accessToken = params.get('access_token');
    const expiresIn   = parseInt(params.get('expires_in') || '0', 10);

    if (!accessToken) {
      showAuthToast('❌ Login failed — no token in redirect.');
      return;
    }

    oauthToken = {
      accessToken,
      expiresAt: expiresIn ? Date.now() + expiresIn * 1000 : null,
    };
    GM_setValue('oauthToken', JSON.stringify(oauthToken));
    showAuthToast('✅ Logged in! Reloading…');
    setTimeout(() => location.reload(), 1200);
  }
  
  popup.onload = handleHash;

  const poll = setInterval(() => {
    if (popup.closed) { clearInterval(poll); return; }
    handleHash();
  }, 50);
}

function logout() {
  oauthToken = null;
  GM_deleteValue('oauthToken');
  showAuthToast('🔓 Logged out. Reloading…');
  setTimeout(() => location.reload(), 1200);
}

function showAuthToast(msg) {
  const el = document.createElement('div');
  el.textContent = msg;
  el.style.cssText = [
    'position:fixed;bottom:24px;right:24px;z-index:99999',
    'background:#1a1a2e;color:#fff;padding:12px 18px',
    'border-radius:8px;font-size:13px;font-weight:600',
    'box-shadow:0 4px 20px rgba(0,0,0,.5)',
    'transition:opacity .4s ease',
  ].join(';');
  document.body.appendChild(el);
  setTimeout(() => { el.style.opacity = '0'; }, 3600);
  setTimeout(() => el.remove(), 4100);
}

let dubInfo = null;
try { dubInfo = JSON.parse(GM_getValue('dubInfo', 'null')); } catch (_) {}

let dubLookup = new Map();

let anilistToMalMap = new Map();
try {
  const stored = JSON.parse(GM_getValue('anilistToMalMap', '{}'));
  anilistToMalMap = new Map(Object.entries(stored).map(([k, v]) => [+k, v]));
} catch (_) {}

// Active filter state
let dubFilter = GM_getValue('dubFilter', 'filter-off');
if (!FILTER_OPTS.includes(dubFilter)) dubFilter = 'filter-off';

function buildDubLookup() {
  dubLookup = new Map();
  if (!dubInfo) return;
  for (const [key, ids] of Object.entries(dubInfo)) {
    for (const id of ids) dubLookup.set(id, key);
  }
}

function getDubKey(malId) {
  return (malId && dubLookup.get(malId)) || 'no';
}

function cacheDubs() {
  const lastCached = GM_getValue('dubCacheDate', 0);
  if (dubInfo && Date.now() - lastCached < CACHE_TTL_MS) {
    buildDubLookup();
    return;
  }
  GM_xmlhttpRequest({
    method: 'GET',
    url: DUB_INFO_URL,
    nocache: true,
    revalidate: true,
    onload(res) {
      try {
        dubInfo = JSON.parse(res.responseText);
        buildDubLookup();
        GM_setValue('dubInfo', res.responseText);
        GM_setValue('dubCacheDate', Date.now());
        runAllLabelers();
      } catch (_) {}
    },
  });
}


function buildHeaders() {
  const headers = {
    'Content-Type': 'application/json',
    'Accept': 'application/json',
  };
  const auth = authHeader();
  if (auth) headers['Authorization'] = auth;
  return headers;
}

function fetchMalIds(anilistIds, callback) {
  const unknownIds = anilistIds.filter(id => !anilistToMalMap.has(id));
  if (!unknownIds.length) { callback(); return; }

  const chunks = [];
  for (let i = 0; i < unknownIds.length; i += GQL_PAGE_SIZE) {
    chunks.push(unknownIds.slice(i, i + GQL_PAGE_SIZE));
  }

  let remaining = chunks.length;

  function onChunkDone() {
    if (--remaining > 0) return;
    const obj = {};
    anilistToMalMap.forEach((v, k) => { obj[k] = v; });
    GM_setValue('anilistToMalMap', JSON.stringify(obj));
    callback();
  }

  for (const chunk of chunks) {
    GM_xmlhttpRequest({
      method: 'POST',
      url: GRAPHQL_URL,
      headers: buildHeaders(),  // ← auth header injected here when logged in
      data: JSON.stringify({ query: GQL_QUERY, variables: { ids: chunk } }),
      onload(res) {
        // Handle token expiry (401) — clear token and retry anonymously
        if (res.status === 401 || res.status === 403) {
          console.warn('[AniList Dubs] Token rejected (status ' + res.status + '). Clearing token.');
          oauthToken = null;
          GM_deleteValue('oauthToken');
          registerMenu();
          // Retry this chunk without auth
          GM_xmlhttpRequest({
            method: 'POST',
            url: GRAPHQL_URL,
            headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
            data: JSON.stringify({ query: GQL_QUERY, variables: { ids: chunk } }),
            onload(res2) {
              parseChunkResponse(res2, chunk);
              onChunkDone();
            },
            onerror: onChunkDone,
          });
          return;
        }
        parseChunkResponse(res, chunk);
        onChunkDone();
      },
      onerror: onChunkDone,
    });
  }
}

function parseChunkResponse(res, chunk) {
  try {
    const media = JSON.parse(res.responseText)?.data?.Page?.media ?? [];
    for (const m of media) anilistToMalMap.set(m.id, m.idMal ?? null);
    for (const id of chunk) {
      if (!anilistToMalMap.has(id)) anilistToMalMap.set(id, null);
    }
  } catch (_) {}
}


function getAnilistId(url) {
  if (!url) return null;
  const m = ANIME_ID_RE.exec(url);
  return m ? +m[1] : null;
}

function extractIds(elements, linkSelector) {
  const ids = new Set();
  for (const el of elements) {
    const link = el.querySelector(linkSelector);
    const id = link && getAnilistId(link.href || link.getAttribute('href'));
    if (id) ids.add(id);
  }
  return [...ids];
}

// Browse / Search  (.media-card)
function labelAllAnilistCards(root = document.body) {
  if (!dubInfo) return;
  const cards = [...root.querySelectorAll('.media-card:not([data-dub])')];
  if (!cards.length) return;

  const ids = extractIds(cards, 'a.cover, a.title');
  fetchMalIds(ids, () => {
    for (const card of cards) {
      const link = card.querySelector('a.cover') || card.querySelector('a.title');
      if (!link) continue;
      const id = getAnilistId(link.href || link.getAttribute('href'));
      if (id) card.dataset.dub = getDubKey(anilistToMalMap.get(id));
    }
  });
}

// User anime list  (.entry-card)
function labelListEntries(root = document.body) {
  if (!dubInfo) return;
  const entries = [...root.querySelectorAll('.entry-card:not([data-dub])')];
  if (!entries.length) return;

  const ids = extractIds(entries, '.title a[href*="/anime/"]');
  fetchMalIds(ids, () => {
    for (const entry of entries) {
      const link = entry.querySelector('.title a[href*="/anime/"]');
      if (!link) continue;
      const id = getAnilistId(link.href || link.getAttribute('href'));
      if (!id) continue;

      const dubKey = getDubKey(anilistToMalMap.get(id));
      entry.dataset.dub = dubKey;

      if (dubKey === 'no') continue;

      const titleEl = entry.querySelector('.title');
      if (!titleEl || titleEl.querySelector('.mal-dubs-list-badge')) continue;
      const badge = document.createElement('span');
      badge.className = dubKey === 'partial'
        ? 'mal-dubs-list-badge partial'
        : 'mal-dubs-list-badge';
      badge.textContent = dubKey === 'partial' ? 'PART DUB' : 'DUB';
      titleEl.insertBefore(badge, titleEl.firstChild);
    }
  });
}

// Anime detail page  (.media-page-unscoped)
function labelAnimePage() {
  if (!dubInfo) return;
  const id = getAnilistId(location.href);
  if (!id) return;
  const mediaEl = document.querySelector('.media.media-page-unscoped');
  if (!mediaEl || mediaEl.hasAttribute('data-dub')) return;

  fetchMalIds([id], () => {
    mediaEl.dataset.dub = getDubKey(anilistToMalMap.get(id));
  });
}



let menuIds = [];

function applyFilter() {
  for (const opt of FILTER_OPTS) document.body.classList.remove('mal-dubs-' + opt);
  document.body.classList.add('mal-dubs-' + dubFilter);
}

function setFilter(opt) {
  dubFilter = opt;
  GM_setValue('dubFilter', opt);
  applyFilter();
  registerMenu();
}



function registerMenu() {
  for (const id of menuIds) GM_unregisterMenuCommand(id);
  menuIds = [];

  // Auth status + login/logout
  const authLabel = oauthToken
    ? '🔐 AniList Account: Connected (90 req/min)'
    : '🔓 AniList Account: Anonymous (30 req/min)';
  menuIds.push(GM_registerMenuCommand(authLabel, () => {})); // status display

  if (oauthToken) {
    menuIds.push(GM_registerMenuCommand('   ↳ Log out of AniList', logout));
  } else {
    menuIds.push(GM_registerMenuCommand('   ↳ Log in to AniList (higher rate limit)', login));
  }

  // Separator-like spacer entry
  menuIds.push(GM_registerMenuCommand('─────────────────────', () => {}));

  // Filter options
  for (let i = 0; i < FILTER_OPTS.length; i++) {
    const opt   = FILTER_OPTS[i];
    const label = (dubFilter === opt ? '✓ ' : '\u2003') + '🎙 Dubs: ' + FILTER_LABELS[i];
    menuIds.push(GM_registerMenuCommand(label, () => setFilter(opt)));
  }
}


// CSS
function injectStyles() {
  GM_addStyle(`
    /* ── Browse/Search cards (.media-card) ── */
    .media-card[data-dub]:not([data-dub="no"]) .cover { position: relative; }
    .media-card[data-dub]:not([data-dub="no"]) .cover::after {
      content: 'DUB';
      position: absolute; bottom: 0; left: 0; right: 0;
      background: rgba(61,180,242,.90); color: #fff;
      font-size: 10px; font-weight: 700;
      padding: 4px 0; text-align: center; letter-spacing: 1.5px;
      z-index: 10; pointer-events: none;
    }
    .media-card[data-dub="partial"] .cover::after {
      content: 'PART DUB'; background: rgba(242,145,61,.90);
    }

    /* ── User list badges ── */
    .mal-dubs-list-badge {
      display: inline-block;
      background: #3db4f2; color: #fff !important;
      font-size: 9px; font-weight: 800;
      padding: 1px 5px; border-radius: 3px;
      letter-spacing: 0.8px; margin-right: 6px;
      vertical-align: middle; line-height: 1.6;
      text-decoration: none !important; pointer-events: none;
      flex-shrink: 0;
    }
    .mal-dubs-list-badge.partial { background: #f2913d; }

    /* ── Anime detail page ── */
    .media-page-unscoped[data-dub]:not([data-dub="no"]) .cover-wrap-inner { position: relative; }
    .media-page-unscoped[data-dub]:not([data-dub="no"]) .cover-wrap-inner::after {
      content: 'DUBBED';
      position: absolute; bottom: 0; left: 0; right: 0;
      background: rgba(61,180,242,.92); color: #fff;
      font-size: 11px; font-weight: 800;
      padding: 6px 0; text-align: center; letter-spacing: 2px;
      z-index: 10; pointer-events: none;
    }
    .media-page-unscoped[data-dub="partial"] .cover-wrap-inner::after {
      content: 'PARTIAL DUB'; background: rgba(242,145,61,.92);
    }

    /* ── Filter states ── */
    body.mal-dubs-only-dubs .media-card[data-dub="no"],
    body.mal-dubs-only-dubs .media-card:not([data-dub]),
    body.mal-dubs-only-dubs .entry-card[data-dub="no"],
    body.mal-dubs-only-dubs .entry-card:not([data-dub]) { display: none !important; }

    body.mal-dubs-no-dubs .media-card[data-dub]:not([data-dub="no"]),
    body.mal-dubs-no-dubs .entry-card[data-dub]:not([data-dub="no"]) { display: none !important; }
  `);
}



function runAllLabelers() {
  labelAllAnilistCards();
  labelListEntries();
  labelAnimePage();
}

function watchForChanges() {
  let debounce;
  let lastURL = location.href;

  new MutationObserver(() => {
    clearTimeout(debounce);
    debounce = setTimeout(() => {
      const currentURL = location.href;
      if (currentURL !== lastURL) {
        lastURL = currentURL;
        document.querySelector('.media.media-page-unscoped')
          ?.removeAttribute('data-dub');
      }
      runAllLabelers();
    }, 300);
  }).observe(document.body, { childList: true, subtree: true });
}


// INIT
injectStyles();
applyFilter();
registerMenu();
cacheDubs();
runAllLabelers();
watchForChanges();