// ==UserScript==
// @name MangaDex Library Search/Filter
// @namespace http://tampermonkey.net/
// @version 2.2
// @description Adding Advanced Search page ability to search through your library and filter by reading status, tags, and more.
// @author MrNosferatu
// @match https://mangadex.org/*
// @icon https://www.google.com/s2/favicons?sz=64&domain=mangadex.org
// @grant none
// @downloadURL none
// ==/UserScript==
(function () {
'use strict';
let db;
const DB_NAME = 'MangadexDB';
const MANGA_STORE = 'manga';
const DB_VERSION = 2;
let LibrarySearch = false;
let filterMode = 'server';
let isUpdatingDatabase = false;
let globalFetchInterceptActive = false;
function initDB() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
db = request.result;
resolve(db);
};
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(MANGA_STORE)) {
db.createObjectStore(MANGA_STORE, { keyPath: 'id' });
}
};
});
}
let isInTitlesPage = false;
function cleanupFetchIntercept() {
LibrarySearch = false;
filterMode = 'server';
if (window.originalFetch) {
window.fetch = window.originalFetch;
window.originalFetch = null;
}
}
function setupFetchIntercept() {
if (!window.originalFetch) {
window.originalFetch = window.fetch;
const originalFetch = window.originalFetch;
window.fetch = async function (input, init) {
const url = input instanceof Request ? input.url : input;
if (!window.location.href.startsWith('https://mangadex.org/titles')) {
return originalFetch.call(this, input, init);
}
if (typeof url === 'string' && url.startsWith('https://api.mangadex.org/manga?')) {
const searchParams = parseSearchParams(url);
const results = await searchMangaByParams(searchParams);
if (results) {
return new Response(JSON.stringify(results), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
}
}
return originalFetch.call(this, input, init);
};
}
}
function setupGlobalFetchIntercept() {
if (!globalFetchInterceptActive) {
const originalFetch = window.fetch;
window.fetch = async function(input, init) {
const url = input instanceof Request ? input.url : input;
const response = await originalFetch.call(this, input, init);
try {
const clonedResponse = response.clone();
// Handle manga status updates globally
if (typeof url === 'string' && url === 'https://api.mangadex.org/manga/status') {
const data = await clonedResponse.json();
if (data.result === 'ok') {
updateMangaStatusInDb(data.statuses);
}
}
// Handle manga attributes updates globally
else if (typeof url === 'string' && url.startsWith('https://api.mangadex.org/manga?')) {
const data = await clonedResponse.json();
if (data.result === 'ok') {
updateMangaAttributesInDb(data.data);
}
}
} catch (e) {
console.error('Error in passive update:', e);
}
return response;
};
globalFetchInterceptActive = true;
}
}
async function updateMangaStatusInDb(statusList) {
if (!db) {
await initDB();
}
const transaction = db.transaction(MANGA_STORE, 'readwrite');
const store = transaction.objectStore(MANGA_STORE);
// Update status for existing manga
for (const [id, status] of Object.entries(statusList)) {
try {
const manga = await new Promise((resolve, reject) => {
const request = store.get(id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (manga) {
manga.status = status;
await storeMangaData(manga);
}
} catch (e) {
console.error('Error updating manga status:', e);
}
}
}
async function updateMangaAttributesInDb(mangaList) {
if (!db) {
await initDB();
}
const transaction = db.transaction(MANGA_STORE, 'readwrite');
const store = transaction.objectStore(MANGA_STORE);
for (const newManga of mangaList) {
try {
const existingManga = await new Promise((resolve, reject) => {
const request = store.get(newManga.id);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
if (existingManga) {
const existingDate = new Date(existingManga.attributes.updatedAt);
const newDate = new Date(newManga.attributes.updatedAt);
if (newDate > existingDate) {
// Update only attributes while preserving status
existingManga.attributes = newManga.attributes;
existingManga.relationships = newManga.relationships;
await storeMangaData(existingManga);
}
}
} catch (e) {
console.error('Error updating manga attributes:', e);
}
}
}
function addLoadingMessage() {
const existingMessage = document.querySelector('[data-db-loading-message]');
if (existingMessage) {
return existingMessage;
}
const messageElement = document.createElement('div');
messageElement.setAttribute('data-db-loading-message', '');
messageElement.className = 'overflow-x-auto fill-width mb-4 mt-2';
messageElement.innerHTML = `
`;
let targetElement;
targetElement = document.querySelector('.hidden.md\\:flex.gap-4.items-center.justify-end.mt-8');
if (targetElement) {
targetElement.parentNode.insertBefore(messageElement, targetElement);
}
return messageElement;
}
function checkUrl() {
const isTitlesPage = document.title.includes('Advanced Search - MangaDex');
if (isTitlesPage && !isInTitlesPage) {
const loadingMessage = addLoadingMessage();
UpdateDatabase().then(() => {
loadingMessage.remove();
addFilterOptions();
});
} else if (!isTitlesPage && isInTitlesPage) {
cleanupFetchIntercept();
}
isInTitlesPage = isTitlesPage;
}
function delayedCheckUrl() {
setTimeout(checkUrl, 100);
}
const intervalId = setInterval(() => {
if (checkUrl()) {
clearInterval(intervalId);
}
}, 50);
window.addEventListener('popstate', delayedCheckUrl);
window.addEventListener('pushState', delayedCheckUrl);
window.addEventListener('replaceState', delayedCheckUrl);
(function (history) {
const pushState = history.pushState;
const replaceState = history.replaceState;
history.pushState = function (state) {
if (typeof history.onpushstate == "function") {
history.onpushstate({ state: state });
}
const result = pushState.apply(history, arguments);
window.dispatchEvent(new Event('pushState'));
return result;
};
history.replaceState = function (state) {
if (typeof history.onreplacestate == "function") {
history.onreplacestate({ state: state });
}
const result = replaceState.apply(history, arguments);
window.dispatchEvent(new Event('replaceState'));
return result;
};
})(window.history);
function addFilterOptions() {
const existingSelector = document.querySelector('[data-db-mode-selector]');
if (existingSelector) {
existingSelector.remove();
}
const filterModeElement = document.createElement('div');
filterModeElement.setAttribute('data-v-9c7a6448', '');
filterModeElement.setAttribute('data-db-mode-selector', '');
filterModeElement.className = 'overflow-x-auto fill-width mb-4 mt-2';
filterModeElement.innerHTML = `
`;
// Add event listeners for DB mode switching
const tabs = filterModeElement.querySelectorAll('.select__tab');
const checkbox = filterModeElement.querySelector('input[name="db_mode"]');
const checkedIcon = filterModeElement.querySelector('.checked-icon');
const uncheckedIcon = filterModeElement.querySelector('.unchecked-icon');
const libraryModes = filterModeElement.querySelectorAll('input[name="library_mode"]');
const libraryTabs = filterModeElement.querySelector('.select__tabs');
// Initially disable library modes
libraryTabs.style.pointerEvents = 'none';
libraryTabs.style.opacity = '0.5';
checkbox.addEventListener('change', (e) => {
if (e.target.checked) {
checkedIcon.style.display = 'block';
uncheckedIcon.style.display = 'none';
libraryTabs.style.pointerEvents = 'auto';
libraryTabs.style.opacity = '1';
// When enabling checkbox, trigger the currently selected library mode
const selectedMode = filterModeElement.querySelector('input[name="library_mode"]:checked');
if (selectedMode) {
filterMode = selectedMode.id;
LibrarySearch = true;
setupFetchIntercept();
}
} else {
checkedIcon.style.display = 'none';
uncheckedIcon.style.display = 'block';
libraryTabs.style.pointerEvents = 'none';
libraryTabs.style.opacity = '0.5';
LibrarySearch = false;
filterMode = 'server';
cleanupFetchIntercept();
}
});
libraryModes.forEach(input => {
input.addEventListener('change', (e) => {
if (!window.location.href.startsWith('https://mangadex.org/titles')) {
return;
}
if (checkbox.checked) {
LibrarySearch = true;
filterMode = e.target.id;
tabs.forEach(t => t.classList.remove('active'));
e.target.nextElementSibling.classList.add('active');
setupFetchIntercept();
}
});
});
let targetElement;
const intervalId = setInterval(() => {
targetElement = document.querySelector('.hidden.md\\:flex.gap-4.items-center.justify-end.mt-8');
if (targetElement) {
clearInterval(intervalId);
targetElement.parentNode.insertBefore(filterModeElement, targetElement);
}
}, 100);
}
function getAccessToken() {
const oidcKey = Object.keys(localStorage).find(key => key.startsWith('oidc.user:'));
if (!oidcKey) return null;
try {
const oidcData = JSON.parse(localStorage.getItem(oidcKey));
return oidcData.access_token;
} catch (e) {
console.error('Error parsing OIDC data:', e);
return null;
}
}
async function fetchMangaDetails(mangaIds) {
const queryString = mangaIds.map(id => `ids[]=${id}`).join('&');
try {
const response = await fetch(`https://api.mangadex.org/manga?${queryString}&limit=100&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&contentRating[]=pornographic&includes[]=cover_art&includes[]=artist&includes[]=author`);
const data = await response.json();
return data;
} catch (e) {
console.error('Error fetching manga details:', e);
return null;
}
}
async function storeMangaData(manga) {
const transaction = db.transaction(MANGA_STORE, 'readwrite');
const store = transaction.objectStore(MANGA_STORE);
return store.put(manga);
}
async function deleteUnusedManga(statusList) {
const transaction = db.transaction(MANGA_STORE, 'readwrite');
const store = transaction.objectStore(MANGA_STORE);
return new Promise((resolve, reject) => {
const request = store.openCursor();
request.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
if (!statusList[cursor.value.id]) {
cursor.delete();
}
cursor.continue();
} else {
resolve();
}
};
request.onerror = () => reject(request.error);
});
}
async function UpdateDatabase() {
const token = getAccessToken();
if (!token) return;
try {
isUpdatingDatabase = true;
const response = await fetch('https://api.mangadex.org/manga/status', {
headers: { 'Authorization': 'Bearer ' + token }
});
const data = await response.json();
if (data.result === 'ok') {
const db = await initDB();
// Update existing manga statuses first
for (const [id, status] of Object.entries(data.statuses)) {
const transaction = db.transaction(MANGA_STORE, 'readwrite');
const store = transaction.objectStore(MANGA_STORE);
const existingManga = await new Promise(resolve => {
store.get(id).onsuccess = (e) => resolve(e.target.result);
});
if (existingManga) {
existingManga.status = status;
await storeMangaData(existingManga);
}
}
await deleteUnusedManga(data.statuses);
const missingMangaIds = [];
for (const [id, status] of Object.entries(data.statuses)) {
const transaction = db.transaction(MANGA_STORE, 'readonly');
const store = transaction.objectStore(MANGA_STORE);
const exists = await new Promise(resolve => {
store.count(id).onsuccess = (e) => resolve(e.target.result > 0);
});
if (!exists) {
missingMangaIds.push(id);
}
}
for (let i = 0; i < missingMangaIds.length; i += 100) {
const chunk = missingMangaIds.slice(i, i + 100);
const mangaData = await fetchMangaDetails(chunk);
if (mangaData && mangaData.result === 'ok') {
for (const manga of mangaData.data) {
manga.status = data.statuses[manga.id];
await storeMangaData(manga);
}
}
if (i + 100 < missingMangaIds.length) {
await new Promise(resolve => setTimeout(resolve, 200));
}
}
}
} catch (error) {
console.error('Error updating database:', error);
} finally {
isUpdatingDatabase = false;
}
}
async function searchMangaByParams(params) {
const allManga = await getAllManga();
if (!allManga || allManga.length === 0) return null;
const {
includedTags = [],
excludedTags = [],
includedTagsMode = 'AND',
excludedTagsMode = 'OR',
originLang = [],
onlyAvailableChapters = false,
author = [],
statuses = [],
content = [],
demos = [],
artist = [],
year = null,
translatedLang = [],
limit = 32,
offset = 0,
title = '',
order = {}
} = params;
const numLimit = parseInt(limit);
const numOffset = parseInt(offset);
let filteredData = allManga;
if (filterMode === 'reading') {
filteredData = filteredData.filter(manga => manga.status === 'reading');
} else if (filterMode === 'plan_to_read') {
filteredData = filteredData.filter(manga => manga.status === 'plan_to_read');
} else if (filterMode === 'completed') {
filteredData = filteredData.filter(manga => manga.status === 'completed');
} else if (filterMode === 'on_hold') {
filteredData = filteredData.filter(manga => manga.status === 'on_hold');
} else if (filterMode === 're_reading') {
filteredData = filteredData.filter(manga => manga.status === 're_reading');
} else if (filterMode === 'dropped') {
filteredData = filteredData.filter(manga => manga.status === 'dropped');
}
const matchedManga = filteredData.filter(manga => {
const mangaTags = manga.attributes.tags.map(tag => tag.id);
const mangaLang = manga.attributes.originalLanguage;
const mangaAuthors = manga.relationships.filter(rel => rel.type === 'author').map(author => author.id);
const mangaArtists = manga.relationships.filter(rel => rel.type === 'artist').map(artist => artist.id);
const mangaStatus = manga.attributes.status;
const mangaContentRating = manga.attributes.contentRating;
const mangaDemographics = manga.attributes.publicationDemographic;
const mangaYear = manga.attributes.year;
const mangaTranslatedLang = manga.attributes.availableTranslatedLanguages;
const mangaTitle = Object.values(manga.attributes.title).join(' ');
const mangaAltTitles = manga.attributes.altTitles.map(altTitle => Object.values(altTitle).join(' ')).join(' ');
const includedTagsMatch = includedTags.length === 0 || (
includedTagsMode === 'AND'
? includedTags.every(tag => mangaTags.includes(tag))
: includedTags.some(tag => mangaTags.includes(tag))
);
const excludedTagsMatch = excludedTags.length === 0 || (
excludedTagsMode === 'AND'
? excludedTags.every(tag => !mangaTags.includes(tag))
: excludedTags.every(tag => !mangaTags.includes(tag))
);
return (
includedTagsMatch &&
excludedTagsMatch &&
(originLang.length === 0 || originLang.includes(mangaLang)) &&
(!onlyAvailableChapters || manga.attributes.availableChapters > 0) &&
(author.length === 0 || author.some(a => mangaAuthors.includes(a))) &&
(statuses.length === 0 || statuses.includes(mangaStatus)) &&
(content.length === 0 || content.includes(mangaContentRating)) &&
(demos.length === 0 || demos.includes(mangaDemographics)) &&
(artist.length === 0 || artist.some(a => mangaArtists.includes(a))) &&
(!year || mangaYear === parseInt(year)) &&
(translatedLang.length === 0 || translatedLang.some(lang => mangaTranslatedLang.includes(lang))) &&
(title === '' || title === null || mangaTitle.toLowerCase().includes(title.toLowerCase()) || mangaAltTitles.toLowerCase().includes(title.toLowerCase()))
);
});
let sortedManga = [...matchedManga];
Object.entries(order).forEach(([key, direction]) => {
sortedManga.sort((a, b) => {
let valueA, valueB;
switch(key) {
case 'title':
valueA = Object.values(a.attributes.title)[0]?.toLowerCase() || '';
valueB = Object.values(b.attributes.title)[0]?.toLowerCase() || '';
break;
case 'createdAt':
valueA = new Date(a.attributes.createdAt).getTime();
valueB = new Date(b.attributes.createdAt).getTime();
break;
default:
return 0;
}
if (direction === 'desc') {
[valueA, valueB] = [valueB, valueA];
}
return valueA < valueB ? -1 : valueA > valueB ? 1 : 0;
});
});
const paginatedManga = sortedManga.slice(numOffset, numOffset + numLimit);
return {
result: "ok",
response: "collection",
data: paginatedManga,
limit: numLimit,
offset: numOffset,
total: sortedManga.length
};
}
async function getAllManga() {
const transaction = db.transaction(MANGA_STORE, 'readonly');
const store = transaction.objectStore(MANGA_STORE);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
function parseSearchParams(url) {
const urlParams = new URL(url).searchParams;
return {
includedTags: urlParams.getAll('includedTags[]'),
excludedTags: urlParams.getAll('excludedTags[]'),
includedTagsMode: urlParams.get('includedTagsMode') || 'AND',
excludedTagsMode: urlParams.get('excludedTagsMode') || 'OR',
originLang: urlParams.getAll('originalLanguage[]'),
onlyAvailableChapters: urlParams.get('onlyAvailableChapters') === 'true',
author: urlParams.getAll('authors[]'),
statuses: urlParams.getAll('status[]'),
content: urlParams.getAll('contentRating[]'),
demos: urlParams.getAll('publicationDemographic[]'),
artist: urlParams.getAll('artists[]'),
year: urlParams.get('year'),
translatedLang: urlParams.getAll('availableTranslatedLanguage[]'),
limit: urlParams.get('limit') || 32,
offset: urlParams.get('offset') || 0,
title: urlParams.get('title') || '',
order: Object.fromEntries(
Array.from(urlParams.entries())
.filter(([key]) => key.startsWith('order['))
.map(([key, value]) => [key.match(/\[(.*?)\]/)[1], value])
)
};
}
})();