|
<!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> |