Skip to content

Instantly share code, notes, and snippets.

@tylers-username
Last active January 16, 2026 22:43
Show Gist options
  • Select an option

  • Save tylers-username/0895fa682a0bd1de275b95a5d0093e89 to your computer and use it in GitHub Desktop.

Select an option

Save tylers-username/0895fa682a0bd1de275b95a5d0093e89 to your computer and use it in GitHub Desktop.

Kobo → Audiobookshelf (Simple Web UI)

This is a tiny index.html page that lets a Kobo browser search + download from your Audiobookshelf server.


1) Get your Audiobookshelf API key

In Audiobookshelf:

  • Go to Settings
  • Find API Keys / Tokens
  • Copy your token (you’ll use it in a URL)

Keep it private — whoever has this token can access your server.


2) Update index.html

Open index.html and change only this variable:

Keep the trailing slash.

<script type="text/javascript">
  // ✅ Set your Audiobookshelf base URL here (keep trailing slash)
  var BOOKSHELF_BASE = "https://your-audiobookshelf-domain/";

  // rest of file stays the same...
</script>

Example:

var BOOKSHELF_BASE = "https://audiobooks.yourdomain.com/";

3) Run the Docker container

Use this Docker Compose file:

services:
  kobo-fetcher:
    image: busybox:latest
    restart: unless-stopped
    command: ["httpd", "-f", "-p", "8080", "-h", "/www"]
    networks:
      - library_network
    ports:
      - 8080:8080 # Host port (the first number) may be changed
    volumes:
      - /host/path/to/index.html:/www/index.html

Start it:

docker compose up -d

4) Build the URL you will open on Kobo

This page expects your API token in the query string:

http://YOUR_SERVER_IP:8080/?token=YOUR_AUDIOBOOKSHELF_API_TOKEN

Example:

http://192.168.1.50:8080/?token=abc123xyz

5) Set your Kobo Beta Browser default URL

On your Kobo:

  • Open Beta Features
  • Open Web Browser
  • Set the Home / Default URL to your link from Step 4

Example:

http://192.168.1.50:8080/?token=abc123xyz

Now opening the browser should immediately load your Audiobookshelf UI.


Notes / Warnings

  • This URL contains your API token. Don’t share it.
  • If you want remote access, put this behind HTTPS via your reverse proxy (recommended).
services:
kobo-fetcher:
image: busybox:latest
restart: unless-stopped
command: ["httpd", "-f", "-p", "8080", "-h", "/www"]
networks:
- library_network
ports:
- 8080:8080 # Host port (the first number) may be changed
volumes:
- /host/path/to/index.html:/www/index.html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Kobo Audiobookshelf</title>
<meta name="viewport" content="width=733,height=763">
<style>
body {
font-family: sans-serif;
background: #fff;
color: #000;
margin: 0;
padding: 30px;
font-size: 40px;
line-height: 1.4;
}
.hidden {
display: none;
}
.notice {
padding: 25px;
border: 3px solid #000;
margin-bottom: 30px;
font-size: 40px;
}
.bar {
margin-bottom: 25px;
}
.bar input,
.bar select,
.bar button {
height: 100px;
font-size: 40px;
margin-right: 10px;
}
.btn {
display: inline-block;
padding: 15px 30px;
border: 3px solid #000;
background: #fff;
text-decoration: none;
color: #000;
font-size: 40px;
}
.btn[disabled] {
opacity: .5;
border-color: #666;
color: #666;
}
.list {}
.item {
border-top: 3px solid #ccc;
padding: 20px 0;
overflow: hidden;
}
.cover {
float: left;
width: 180px;
height: 180px;
margin-right: 20px;
background: #eee;
object-fit: cover;
}
.meta {
overflow: hidden;
}
.title {
font-size: 40px;
margin-bottom: 10px;
}
.author {
font-size: 35px;
color: #333;
margin-bottom: 15px;
}
.downloads a,
.downloads button {
margin-right: 15px;
font-size: 35px;
}
.pager {
margin-top: 25px;
text-align: center;
}
.pager .btn {
width: 250px;
text-align: center;
margin: 0 10px;
font-size: 40px;
}
.pager .info {
display: inline-block;
min-width: 300px;
text-align: center;
font-size: 40px;
}
.range {
font-size: 35px;
line-height: 1.3;
}
</style>
</head>
<body>
<div id="status" class="notice">Checking access…</div>
<div id="app" class="hidden">
<div class="bar">
<select id="librarySelect" onchange="app.changeLibrary(this.value)" class="hidden"></select>
<form id="searchForm" onsubmit="return app.submitSearch()">
<input id="q" type="text" placeholder="Search titles">
<select id="kind" style="display:none">
<option value="all">All</option>
<option value="ebook">eBook</option>
<option value="audiobook">Audiobook</option>
</select>
<button type="submit" class="btn">Search</button>
<button type="button" class="btn" onclick="app.clearSearch()">Clear</button>
</form>
</div>
<!-- NEW: top pagination range info -->
<div id="rangeBar" class="bar hidden">
<div id="rangeInfo" class="range"></div>
</div>
<div id="grid" class="list"></div>
<div class="pager">
<button id="firstBtn" class="btn" onclick="app.first()">First</button>
<button id="prevBtn" class="btn" onclick="app.prev()">Prev</button>
<span id="pageInfo" class="info"></span>
<button id="nextBtn" class="btn" onclick="app.next()">Next</button>
<button id="lastBtn" class="btn" onclick="app.last()">Last</button>
</div>
</div>
<script type="text/javascript">
/* NEW: keep Audiobookshelf base URL as a single variable */
var AUDIOBOOKSHELF_BASE = 'https://your-audiobookshelf-domain/';
function byId(id) { return document.getElementById(id); }
function text(el, s) { el.innerHTML = ''; el.appendChild(document.createTextNode(s)); }
function fromUrl(name) {
var q = location.search.replace(/^\?/, '').split('&'), i;
for (i = 0; i < q.length; i++) {
var kv = q[i].split('=');
if (decodeURIComponent(kv[0] || '') === name) {
return decodeURIComponent(kv[1] || '');
}
}
return '';
}
function getJSON(u, ok, fail) {
var x = new XMLHttpRequest();
x.open('GET', u, true);
x.onreadystatechange = function () {
if (x.readyState === 4) {
if (x.status >= 200 && x.status < 300) {
ok(JSON.parse(x.responseText));
}
else {
if (fail) fail(x);
}
}
};
x.send(null);
}
function createApp() {
var state = { base: AUDIOBOOKSHELF_BASE, token: '', libraryId: null, page: 1, pageSize: 10, q: '', kind: 'all', total: 0 };
function url(path, params) {
var s = state.base + path, q = 'token=' + encodeURIComponent(state.token), k;
for (k in params) {
if (params.hasOwnProperty(k) && params[k] !== '' && params[k] !== null) {
q += '&' + encodeURIComponent(k) + '=' + encodeURIComponent(params[k]);
}
}
return s + (s.indexOf('?') > -1 ? '&' : '?') + q;
}
// NEW: top pagination text (first + last title on the current page)
function updateRange(items) {
var bar = byId('rangeBar');
var el = byId('rangeInfo');
if (!bar || !el) { return; }
if (!items || !items.length) {
bar.className = 'bar hidden';
el.innerHTML = '';
return;
}
function titleOf(it) {
var media = it && it.media ? it.media : {};
var meta = media && media.metadata ? media.metadata : {};
return (meta && meta.title) ? meta.title : 'Untitled';
}
var first = titleOf(items[0]);
var last = titleOf(items[items.length - 1]);
bar.className = 'bar';
text(el, 'Showing: ' + first + ' — ' + last);
}
// Build downloads area using either existing media or a detail fetch when missing (fixes search results bug)
function renderDownloads(item, container) {
container.innerHTML = '';
function inferFormatFromName(name) {
if (!name) return '';
var m = /\.([a-z0-9]+)$/i.exec(name);
return m ? m[1].toUpperCase() : '';
}
function buildLinks(source) {
container.innerHTML = '';
var media = source.media || {};
var ebookFile = media.ebookFile || null;
var audioFiles = media.audioFiles || [];
var ebookFmt = media.ebookFormat || inferFormatFromName(ebookFile && (ebookFile.name || ebookFile.filename));
// --- eBook link ---
if (ebookFile && ebookFile.ino) {
var ea = document.createElement('a');
ea.className = 'btn';
ea.href = state.base + "api/items/" + source.id + "/file/" + ebookFile.ino + "/download?token=" + encodeURIComponent(state.token);
ea.appendChild(document.createTextNode('eBook' + (ebookFmt ? ' (' + ebookFmt + ')' : '')));
if (ebookFile.name) ea.setAttribute('download', ebookFile.name);
container.appendChild(ea);
} else if (ebookFile && !ebookFile.ino) {
var waiting = document.createElement('span');
waiting.appendChild(document.createTextNode('Loading eBook… '));
container.appendChild(waiting);
getJSON(state.base + "api/items/" + source.id + "?token=" + encodeURIComponent(state.token), function (detail) {
buildLinks(detail);
}, function () {
waiting.textContent = 'eBook (Unavailable)';
});
} else {
container.appendChild(document.createTextNode('No eBook'));
}
container.appendChild(document.createTextNode(' '));
// --- Audiobook link ---
if (audioFiles && audioFiles.length > 0 && audioFiles[0] && audioFiles[0].ino) {
var f = audioFiles[0];
var ab = document.createElement('a');
ab.className = 'btn';
ab.href = state.base + "api/items/" + source.id + "/file/" + f.ino + "/download?token=" + encodeURIComponent(state.token);
ab.appendChild(document.createTextNode('Audiobook'));
if (f.metadata && f.metadata.filename) {
ab.setAttribute('download', f.metadata.filename);
}
container.appendChild(ab);
} else if (audioFiles && audioFiles.length > 0 && audioFiles[0] && !audioFiles[0].ino) {
var waitingAb = document.createElement('span');
waitingAb.appendChild(document.createTextNode('Loading audiobook…'));
container.appendChild(document.createTextNode(' '));
container.appendChild(waitingAb);
getJSON(state.base + "api/items/" + source.id + "?token=" + encodeURIComponent(state.token), function (detail) {
buildLinks(detail);
}, function () {
waitingAb.textContent = 'Audiobook (Unavailable)';
});
} else {
container.appendChild(document.createTextNode('No Audiobook'));
}
}
var hasFilesInSummary = item && item.media && (item.media.ebookFile || (item.media.audioFiles && item.media.audioFiles.length));
if (hasFilesInSummary) {
buildLinks(item);
} else {
var loading = document.createElement('span');
loading.appendChild(document.createTextNode('Loading formats…'));
container.appendChild(loading);
getJSON(state.base + "api/items/" + item.id + "?token=" + encodeURIComponent(state.token), function (detail) {
buildLinks(detail);
}, function () {
container.innerHTML = '';
container.appendChild(document.createTextNode('Formats unavailable'));
});
}
}
function show(items) {
var grid = byId('grid'), i;
grid.innerHTML = '';
for (i = 0; i < items.length; i++) {
var it = items[i];
var media = it.media || {};
var meta = media.metadata || {};
var row = document.createElement('div'),
img = document.createElement('img'),
metaDiv = document.createElement('div'),
t = document.createElement('div'),
a = document.createElement('div'),
d = document.createElement('div');
row.className = 'item';
img.className = 'cover';
img.src = state.base + "api/items/" + it.id + "/cover?token="
+ encodeURIComponent(state.token) + "&format=jpg";
img.alt = 'cover';
metaDiv.className = 'meta';
t.className = 'title';
t.appendChild(document.createTextNode((meta && meta.title) || 'Untitled'));
a.className = 'author';
if (meta && meta.authorName) a.appendChild(document.createTextNode(meta.authorName));
d.className = 'downloads';
renderDownloads(it, d);
metaDiv.appendChild(t);
if (meta && meta.authorName) metaDiv.appendChild(a);
metaDiv.appendChild(d);
row.appendChild(img);
row.appendChild(metaDiv);
grid.appendChild(row);
}
}
function pageInfo(hasNext) {
var max = state.total ? Math.ceil(state.total / state.pageSize) : (hasNext ? state.page + 1 : state.page);
if (state.total && max < 1) { max = 1; }
text(byId('pageInfo'), 'Page ' + state.page + (state.total ? ' of ' + max : ''));
byId('firstBtn').disabled = state.page <= 1;
byId('prevBtn').disabled = state.page <= 1;
byId('nextBtn').disabled = !hasNext && (state.total ? state.page >= max : true);
byId('lastBtn').disabled = state.total ? state.page >= max : !hasNext;
}
function fetchItems() {
if (!state.libraryId) { return; }
var params = { limit: state.pageSize, page: state.page - 1 };
if (state.q) { params.q = state.q; }
if (state.kind !== 'all') { params.filter = state.kind; }
var endpoint = state.q
? 'api/libraries/' + state.libraryId + '/search'
: 'api/libraries/' + state.libraryId + '/items';
getJSON(url(endpoint, params), function (res) {
var items = [];
if (state.q) {
if (res.book) {
for (var i = 0; i < res.book.length; i++) {
if (res.book[i].libraryItem) {
items.push(res.book[i].libraryItem);
}
}
}
state.total = items.length;
} else {
items = (res && res.results) || [];
state.total = res && res.total ? res.total : 0;
}
show(items);
updateRange(items);
pageInfo(items.length === state.pageSize || (state.total ? (state.page * state.pageSize) < state.total : false));
}, function () { text(byId('grid'), 'Cannot load items'); });
}
return {
init: function (token, libs) {
state.token = token;
var ps = fromUrl('perPage') || fromUrl('pageSize') || fromUrl('limit');
ps = parseInt(ps, 10);
if (!isNaN(ps) && ps > 0) {
if (ps > 100) ps = 100;
state.pageSize = ps;
}
var select = byId('librarySelect');
if (libs.length > 1) {
select.className = '';
select.innerHTML = '';
for (var i = 0; i < libs.length; i++) {
var opt = document.createElement('option');
opt.value = libs[i].id; opt.text = libs[i].name;
select.appendChild(opt);
}
}
state.libraryId = libs[0].id;
fetchItems();
},
changeLibrary: function (id) {
state.libraryId = id;
state.page = 1; state.q = ''; state.kind = 'all';
fetchItems();
},
first: function () {
if (state.page > 1) {
state.page = 1;
fetchItems();
}
},
last: function () {
if (state.total) {
var max = Math.max(1, Math.ceil(state.total / state.pageSize));
if (state.page !== max) {
state.page = max;
fetchItems();
}
}
},
next: function () { state.page += 1; fetchItems(); },
prev: function () { if (state.page > 1) state.page -= 1; fetchItems(); },
submitSearch: function () {
state.q = byId('q').value || '';
state.kind = byId('kind').value || 'all';
state.page = 1;
fetchItems();
return false;
},
clearSearch: function () {
byId('q').value = '';
byId('kind').value = 'all';
state.q = '';
state.kind = 'all';
state.page = 1;
fetchItems();
try { byId('q').focus(); } catch (e) { }
}
};
}
var app = createApp();
var token = fromUrl('token');
var statusEl = byId('status'), appEl = byId('app');
if (!token) {
statusEl.innerHTML = "❌ No token provided.";
} else {
var check = AUDIOBOOKSHELF_BASE + "api/libraries?token=" + encodeURIComponent(token);
getJSON(check, function (res) {
if (res && res.libraries && res.libraries.length) {
statusEl.className = "hidden";
appEl.className = "";
app.init(token, res.libraries);
} else {
statusEl.innerHTML = "❌ Token valid but no libraries found.";
}
}, function () { statusEl.innerHTML = "❌ Invalid or expired token."; });
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment