Skip to content

Instantly share code, notes, and snippets.

@Lydon-01
Created January 19, 2026 12:07
Show Gist options
  • Select an option

  • Save Lydon-01/6b59667e4d343cad97c9ce796888a498 to your computer and use it in GitHub Desktop.

Select an option

Save Lydon-01/6b59667e4d343cad97c9ce796888a498 to your computer and use it in GitHub Desktop.
Goodreads Libby Availability Checker

Goodreads Libby Availability Checker

A Tampermonkey userscript that checks if books from your Goodreads reading lists are available in your local Libby library.

Version License

Screenshots

Book Page

Book page with Libby availability indicator

Reading List

Reading list with Libby buttons

Features

Works on multiple pages

  • Goodreads "want to read" and other shelf lists
  • Individual book pages

Smart matching

  • Fuzzy title/author matching handles subtitle variations
  • Handles audiobook narrators in author field
  • 3-tier fallback search (full query → main title → title only)
  • Decodes HTML entities (®, ™, etc.)

Format detection

  • 📚 = eBook available
  • 🎧 = Audiobook available
  • 📚🎧 = Both formats available
  • ✗ = Not found in Libby
  • All buttons labeled "Libby" for clarity

Performance optimized

  • 7-day result caching
  • Instant display of cached results
  • Rate-limited API calls (500ms delay)
  • Loading indicators (⏳)
  • Only checks uncached books

Configurable

  • Support for any Libby library
  • Menu commands for easy configuration
  • Clear cache option
  • Format preference (audiobook vs ebook)

Installation

  1. Install Tampermonkey browser extension
  2. Click here to install: goodreads-libby-checker.user.js
  3. Configure your library (see below)

Configuration

Set Your Library

  1. Click the Tampermonkey icon → "Goodreads Libby Availability Checker" → "⚙️ Configure Library"
  2. Enter your library key (found in your Libby URL)
    • Example: https://libbyapp.com/library/westerncape → enter westerncape
  3. Refresh the page

Menu Commands

Access via Tampermonkey menu:

  • ⚙️ Configure Library - Change your Libby library
  • 🗑️ Clear Cache - Force refresh all results
  • 🔄 Toggle Format Preference - Switch between preferring audiobooks or ebooks

Usage

On List Pages

Visit any Goodreads shelf (e.g., "want to read"):

  • Small buttons appear under each book cover
  • 📚 Libby = eBook available
  • 🎧 Libby = Audiobook available
  • 📚🎧 Libby = Both formats available
  • ✗ Libby = Not found
  • Click button to open in Libby

On Book Pages

Visit any individual book page:

  • Banner appears below the title
  • Shows available formats
  • Click "View in Libby" to borrow

How It Works

  1. Extracts book title and author from Goodreads
  2. Queries Libby's API (OverDrive Thunder API) with 3-tier fallback:
    • Full author + full title
    • Author + main title (without subtitle)
    • Main title only
  3. Uses smart fuzzy matching:
    • Handles subtitle differences
    • Matches authors even with narrators listed
    • Decodes HTML entities
  4. Caches results for 7 days
  5. Displays availability with direct link

Matching Algorithm

The script uses intelligent matching to handle real-world variations:

  • Title matching: Compares main title (before colon/dash) if full title doesn't match
  • Author matching: Checks if Goodreads author appears in Libby's author list (handles narrators)
  • Thresholds: Title ≥50% + Author ≥40% similarity required
  • Fallback searches: Tries 3 different query strategies to maximize matches

Privacy

  • All data stored locally in your browser
  • No external servers or tracking
  • Only communicates with Goodreads and Libby APIs

Troubleshooting

Books not showing up?

  • Make sure you've configured your library key
  • Check browser console for errors
  • Try clearing cache via menu

Wrong library?

  • Use "Configure Library" menu command
  • Verify your library key from Libby URL

False negatives (book exists but shows ✗)?

  • Check console logs to see matching scores
  • Some books may have very different titles/authors between platforms
  • Try searching manually in Libby to verify

API errors?

  • Your library might not be supported
  • Check if your library uses Libby/OverDrive

Rate Limiting

The script is designed to be respectful of Libby's API:

  • 500ms delay between requests
  • 7-day caching reduces API calls by ~95%
  • 10-second timeout per request
  • Only checks uncached books on reload

Contributing

Issues and pull requests welcome! See CONTRIBUTING.md

License

MIT License - see LICENSE file

Disclaimer

This is an unofficial tool. Not affiliated with Goodreads, Libby, or OverDrive.

Changelog

1.0.5 (2026-01-19)

  • Added "Libby" label to all buttons for clarity
  • Improved button layout with icon + text

1.0.4 (2026-01-19)

  • Fixed author matching to handle narrators
  • Added HTML entity decoding (®, ™, etc.)
  • Improved matching algorithm

1.0.3 (2026-01-19)

  • Added 3-tier fallback search strategy
  • Better handling of subtitles

1.0.2 (2026-01-19)

  • Improved title matching for books with subtitles

1.0.1 (2026-01-19)

  • Optimized cache loading (instant display)
  • Only checks uncached books

1.0.0 (2026-01-19)

  • Initial release
  • Multi-library support
  • Format detection
  • Caching system
  • Configuration menu
// ==UserScript==
// @name Goodreads Libby Availability Checker
// @namespace https://gist.github.com/Lydon-01
// @version 1.0.5
// @description Check if books from your Goodreads lists are available in your Libby library
// @author Lydon Carter
// @homepage https://gist.github.com/Lydon-01
// @supportURL https://gist.github.com/Lydon-01
// @license MIT
// @match https://www.goodreads.com/review/list/*
// @match https://www.goodreads.com/book/show/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @connect thunder.api.overdrive.com
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// ===== CONFIGURATION =====
// Change this to your library's key (find it in your Libby URL)
// Example: https://libbyapp.com/library/westerncape -> 'westerncape'
const LIBRARY_KEY = GM_getValue('library_key', 'westerncape');
const DELAY_MS = 500;
const CACHE_DAYS = 7;
const PREFER_AUDIOBOOK = GM_getValue('prefer_audiobook', true);
// Register menu commands
GM_registerMenuCommand('⚙️ Configure Library', configureLibrary);
GM_registerMenuCommand('🗑️ Clear Cache', clearCache);
GM_registerMenuCommand('🔄 Toggle Format Preference', toggleFormatPreference);
function configureLibrary() {
const newKey = prompt('Enter your Libby library key (from your Libby URL):', LIBRARY_KEY);
if (newKey && newKey.trim()) {
GM_setValue('library_key', newKey.trim());
alert('Library updated! Refresh the page to apply changes.');
}
}
function clearCache() {
if (confirm('Clear all cached Libby results?')) {
const keys = GM_getValue('cache_keys', []);
keys.forEach(key => GM_setValue(key, null));
GM_setValue('cache_keys', []);
alert('Cache cleared! Refresh to check books again.');
}
}
function toggleFormatPreference() {
const current = GM_getValue('prefer_audiobook', true);
GM_setValue('prefer_audiobook', !current);
alert(`Now preferring: ${!current ? 'Audiobooks' : 'eBooks'}\nRefresh to apply.`);
}
// ===== UTILITY FUNCTIONS =====
function normalizeTitle(title) {
return title.toLowerCase()
.replace(/[:\-—–]/g, ' ')
.replace(/\s+/g, ' ')
.replace(/®|™|©/g, '')
.trim();
}
function extractMainTitle(title) {
// Remove common subtitle patterns
return title
.replace(/[:\-—–].*/g, '') // Remove everything after : or -
.replace(/\(.*?\)/g, '') // Remove parentheses
.trim();
}
function authorMatch(goodreadsAuthor, libbyAuthors) {
// Libby often includes narrators, so check if Goodreads author appears in Libby's author list
const grAuthor = normalizeTitle(goodreadsAuthor);
const libbyAuthor = normalizeTitle(libbyAuthors);
// Check if Goodreads author is contained in Libby authors
if (libbyAuthor.includes(grAuthor)) {
return 1.0;
}
// Check word overlap (original method)
const wordsA = new Set(grAuthor.split(' ').filter(w => w.length > 2));
const wordsB = new Set(libbyAuthor.split(' ').filter(w => w.length > 2));
const intersection = new Set([...wordsA].filter(x => wordsB.has(x)));
return wordsA.size === 0 ? 0 : intersection.size / wordsA.size; // Changed denominator to wordsA.size
}
function titleMatch(goodreadsTitle, libbyTitle) {
// Decode HTML entities
const cleanLibby = libbyTitle.replace(/®|™|®|™/g, '');
// Try full title match
const fullScore = titleSimilarity(goodreadsTitle, cleanLibby);
if (fullScore >= 0.7) return fullScore;
// Try main title only (without subtitle)
const mainScore = titleSimilarity(extractMainTitle(goodreadsTitle), extractMainTitle(cleanLibby));
return Math.max(fullScore, mainScore);
}
function titleSimilarity(s1, s2) {
const a = normalizeTitle(s1);
const b = normalizeTitle(s2);
const wordsA = new Set(a.split(' ').filter(w => w.length > 2));
const wordsB = new Set(b.split(' ').filter(w => w.length > 2));
const intersection = new Set([...wordsA].filter(x => wordsB.has(x)));
return wordsA.size === 0 ? 0 : intersection.size / Math.max(wordsA.size, wordsB.size);
}
function getCacheKey(title, author) {
return `libby_${LIBRARY_KEY}_${normalizeTitle(title)}_${normalizeTitle(author)}`;
}
function getCache(title, author) {
const key = getCacheKey(title, author);
const cached = GM_getValue(key);
if (!cached) return null;
try {
const data = JSON.parse(cached);
const age = Date.now() - data.timestamp;
if (age > CACHE_DAYS * 24 * 60 * 60 * 1000) {
GM_setValue(key, null);
return null;
}
return data.result;
} catch (e) {
return null;
}
}
function setCache(title, author, result) {
const key = getCacheKey(title, author);
GM_setValue(key, JSON.stringify({
timestamp: Date.now(),
result: result
}));
// Track cache keys for clearing
const keys = GM_getValue('cache_keys', []);
if (!keys.includes(key)) {
keys.push(key);
GM_setValue('cache_keys', keys);
}
}
// ===== EXTRACTION =====
function extractBooksFromList() {
const books = [];
const rows = document.querySelectorAll('tr.bookalike');
for (const row of rows) {
const titleEl = row.querySelector('.title a');
const authorEl = row.querySelector('.author a');
if (titleEl && authorEl) {
const title = titleEl.textContent.trim();
const author = authorEl.textContent.trim();
// Skip if already processed
if (!row.querySelector('.libby-indicator')) {
books.push({ title, author, row });
}
}
}
return books;
}
function extractBookInfo() {
const titleEl = document.querySelector('h1[data-testid="bookTitle"]');
const authorEl = document.querySelector('[data-testid="name"]');
return titleEl && authorEl ? {
title: titleEl.textContent.trim(),
author: authorEl.textContent.trim()
} : null;
}
// ===== API =====
async function searchLibbyAPI(query) {
const url = `https://thunder.api.overdrive.com/v2/libraries/${LIBRARY_KEY}/media?query=${encodeURIComponent(query)}`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: { 'Accept': 'application/json' },
timeout: 10000,
onload: (response) => {
if (response.status !== 200) {
reject(new Error(`API returned ${response.status}`));
return;
}
try {
resolve(JSON.parse(response.responseText));
} catch (e) {
reject(new Error('Invalid JSON response'));
}
},
onerror: () => reject(new Error('Network error')),
ontimeout: () => reject(new Error('Request timeout'))
});
});
}
function findAllMatches(bookTitle, bookAuthor, apiResults) {
if (!apiResults?.items) return [];
console.log(`Searching for: "${bookTitle}" by "${bookAuthor}"`);
const matches = [];
for (const item of apiResults.items) {
const title = item.title || '';
const authors = item.creators?.map(c => c.name).join(', ') || '';
const titleScore = titleMatch(bookTitle, title);
const authorScore = authorMatch(bookAuthor, authors);
console.log(` "${title}" by ${authors}`);
console.log(` Title: ${titleScore.toFixed(2)}, Author: ${authorScore.toFixed(2)}`);
if (titleScore >= 0.5 && authorScore >= 0.4) {
console.log(` ✓ MATCH`);
matches.push({
titleId: item.id,
format: item.type?.id || 'unknown',
score: titleScore + authorScore
});
} else {
console.log(` ✗ No match (need title≥0.5 AND author≥0.4)`);
}
}
console.log(`Found ${matches.length} matches`);
return matches;
}
function selectBestMatch(matches) {
if (matches.length === 0) return { found: false };
const audiobooks = matches.filter(m => m.format === 'audiobook');
const ebooks = matches.filter(m => m.format === 'ebook');
const hasAudio = audiobooks.length > 0;
const hasEbook = ebooks.length > 0;
let best;
if (PREFER_AUDIOBOOK && hasAudio) {
best = audiobooks.sort((a, b) => b.score - a.score)[0];
} else if (hasEbook) {
best = ebooks.sort((a, b) => b.score - a.score)[0];
} else if (hasAudio) {
best = audiobooks[0];
} else {
best = matches[0];
}
return {
found: true,
titleId: best.titleId,
format: best.format,
hasAudio: hasAudio,
hasEbook: hasEbook
};
}
// ===== UI =====
function addLibbyButtonToList(row, match, isLoading = false) {
const coverCell = row.querySelector('.cover');
if (!coverCell) return;
let button = row.querySelector('.libby-indicator');
if (!button) {
button = document.createElement('div');
button.className = 'libby-indicator';
button.style.cssText = 'margin-top: 5px; padding: 5px; border-radius: 3px; text-align: center; font-size: 11px;';
coverCell.appendChild(button);
}
if (isLoading) {
button.style.background = '#f0f0f0';
button.style.color = '#666';
button.textContent = '⏳';
return;
}
if (match.found) {
const libbyUrl = `https://libbyapp.com/library/${LIBRARY_KEY}/everything/page-1/${match.titleId}`;
button.style.background = '#00635d';
let icon = '📚';
if (match.hasAudio && match.hasEbook) icon = '📚🎧';
else if (match.format === 'audiobook') icon = '🎧';
button.innerHTML = `<a href="${libbyUrl}" target="_blank" style="color: white; text-decoration: none; display: block; font-size: 9px; line-height: 1.2;" title="Available in Libby">${icon}<br>Libby</a>`;
} else {
button.style.background = '#ddd';
button.style.color = '#666';
button.style.fontSize = '9px';
button.style.lineHeight = '1.2';
button.innerHTML = '✗<br>Libby';
button.title = 'Not found in Libby';
}
}
function addLibbyIndicatorToBook(match, isLoading = false) {
const container = document.querySelector('[data-testid="bookTitle"]')?.parentElement;
if (!container) return;
let indicator = container.querySelector('.libby-indicator');
if (!indicator) {
indicator = document.createElement('div');
indicator.className = 'libby-indicator';
indicator.style.cssText = 'margin-top: 10px; padding: 10px; border-radius: 5px;';
container.appendChild(indicator);
}
if (isLoading) {
indicator.style.background = '#f0f0f0';
indicator.style.color = '#666';
indicator.textContent = '⏳ Checking Libby availability...';
return;
}
if (match.found) {
const libbyUrl = `https://libbyapp.com/library/${LIBRARY_KEY}/everything/page-1/${match.titleId}`;
indicator.style.background = '#00635d';
indicator.style.color = 'white';
let formats = [];
if (match.hasEbook) formats.push('eBook');
if (match.hasAudio) formats.push('Audiobook');
indicator.innerHTML = `✓ Available in Libby (${formats.join(' & ')})
<a href="${libbyUrl}" target="_blank" style="color: #fff; text-decoration: underline; margin-left: 10px;">View in Libby</a>`;
} else if (match.error) {
indicator.style.background = '#ffebee';
indicator.style.color = '#c62828';
indicator.innerHTML = `⚠️ Error checking Libby: ${match.error}`;
} else {
indicator.style.background = '#ccc';
indicator.style.color = '#333';
indicator.innerHTML = '✗ Not found in Libby';
}
}
// ===== MAIN LOGIC =====
async function checkBook(book) {
const cached = getCache(book.title, book.author);
if (cached) {
return cached;
}
try {
// Try full query first
let query = `${book.author} ${book.title}`;
console.log(`Query 1: "${query}"`);
let results = await searchLibbyAPI(query);
let matches = findAllMatches(book.title, book.author, results);
// If no matches, try author + main title only
if (matches.length === 0) {
query = `${book.author} ${extractMainTitle(book.title)}`;
console.log(`Query 2 (main title): "${query}"`);
results = await searchLibbyAPI(query);
matches = findAllMatches(book.title, book.author, results);
}
// If still no matches, try just main title
if (matches.length === 0) {
query = extractMainTitle(book.title);
console.log(`Query 3 (title only): "${query}"`);
results = await searchLibbyAPI(query);
matches = findAllMatches(book.title, book.author, results);
}
const match = selectBestMatch(matches);
setCache(book.title, book.author, match);
return match;
} catch (error) {
console.error(`Error checking ${book.title}:`, error);
return { found: false, error: error.message };
}
}
async function initList() {
const books = extractBooksFromList();
if (books.length === 0) return;
console.log(`[Libby Checker] Processing ${books.length} books...`);
// First pass: show cached results immediately
const uncachedBooks = [];
for (const book of books) {
const cached = getCache(book.title, book.author);
if (cached) {
addLibbyButtonToList(book.row, cached, false);
} else {
addLibbyButtonToList(book.row, null, true);
uncachedBooks.push(book);
}
}
if (uncachedBooks.length === 0) {
console.log('[Libby Checker] All results from cache!');
return;
}
console.log(`[Libby Checker] Checking ${uncachedBooks.length} new books...`);
// Second pass: check uncached books
for (let i = 0; i < uncachedBooks.length; i++) {
const book = uncachedBooks[i];
try {
const query = `${book.author} ${book.title}`;
const results = await searchLibbyAPI(query);
const matches = findAllMatches(book.title, book.author, results);
const match = selectBestMatch(matches);
setCache(book.title, book.author, match);
addLibbyButtonToList(book.row, match, false);
} catch (error) {
console.error(`Error checking ${book.title}:`, error);
const errorMatch = { found: false, error: error.message };
addLibbyButtonToList(book.row, errorMatch, false);
}
if (i < uncachedBooks.length - 1) {
await new Promise(resolve => setTimeout(resolve, DELAY_MS));
}
}
console.log('[Libby Checker] Complete!');
}
async function initBook() {
const bookInfo = extractBookInfo();
if (!bookInfo) return;
// Check if already processed
if (document.querySelector('.libby-indicator')) return;
addLibbyIndicatorToBook(null, true);
const match = await checkBook(bookInfo);
addLibbyIndicatorToBook(match, false);
}
function init() {
if (window.location.pathname.includes('/review/list/')) {
initList();
} else if (window.location.pathname.includes('/book/show/')) {
initBook();
}
}
// Run on page load and when navigating (for SPA behavior)
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Watch for dynamic content changes
let lastUrl = location.href;
new MutationObserver(() => {
if (location.href !== lastUrl) {
lastUrl = location.href;
setTimeout(init, 500);
}
}).observe(document.body, { childList: true, subtree: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment