#!/usr/bin/env node const fs = require('fs'); const path = require('path'); const https = require('https'); const CRED_PATH = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env'); function loadCreds() { const raw = fs.readFileSync(CRED_PATH, 'utf8'); const env = {}; for (const line of raw.split('\n')) { const m = line.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; } return env; } function api(endpoint) { const { AUDIOBOOKSHELF_URL, AUDIOBOOKSHELF_TOKEN } = loadCreds(); const url = `${AUDIOBOOKSHELF_URL}${endpoint}`; return new Promise((resolve, reject) => { const req = https.get(url, { headers: { 'Authorization': `Bearer ${AUDIOBOOKSHELF_TOKEN}` } }, (res) => { let data = ''; res.on('data', c => data += c); res.on('end', () => { try { resolve(JSON.parse(data)); } catch { reject(new Error(`Invalid JSON from ${endpoint}`)); } }); }); req.on('error', reject); req.setTimeout(10000, () => { req.destroy(); reject(new Error('Timeout')); }); }); } function formatDuration(secs) { const h = Math.floor(secs / 3600); const m = Math.floor((secs % 3600) / 60); if (h > 0) return `${h}h ${m}m`; return `${m}m`; } function formatProgress(progress) { return `${Math.round(progress * 100)}%`; } async function cmdCurrent() { const data = await api('/api/me/items-in-progress'); const items = data.libraryItems || []; if (items.length === 0) { console.log('Nothing in progress.'); return; } for (const item of items) { const meta = item.media?.metadata || {}; const title = meta.title || 'Unknown'; const author = meta.authorName || 'Unknown'; const narrator = meta.narratorName || '-'; const series = meta.seriesName || '-'; const duration = formatDuration(item.media?.duration || 0); // Get progress from user's mediaProgress const me = await api('/api/me'); const prog = (me.mediaProgress || []).find(p => p.libraryItemId === item.id); const progress = prog ? formatProgress(prog.progress) : '?'; const currentTime = prog ? formatDuration(prog.currentTime) : '?'; const remaining = prog ? formatDuration((item.media?.duration || 0) - (prog.currentTime || 0)) : '?'; console.log(`${title}\t${author}\t${series}\t${progress}\t${currentTime}/${duration}\tremaining:${remaining}\tnarrator:${narrator}`); } } async function cmdLibraries() { const data = await api('/api/libraries'); for (const lib of (data.libraries || [])) { console.log(`${lib.id}\t${lib.name}\t${lib.mediaType}`); } } async function cmdRecent(args) { const limit = args[0] || '5'; const me = await api('/api/me'); const progress = (me.mediaProgress || []) .filter(p => !p.isFinished) .sort((a, b) => (b.lastUpdate || 0) - (a.lastUpdate || 0)) .slice(0, parseInt(limit, 10)); if (progress.length === 0) { console.log('No recent listening activity.'); return; } for (const p of progress) { try { const item = await api(`/api/items/${p.libraryItemId}?expanded=1`); const meta = item.media?.metadata || {}; const title = meta.title || 'Unknown'; const author = meta.authorName || 'Unknown'; const series = meta.seriesName || '-'; const pct = formatProgress(p.progress); const lastUpdate = new Date(p.lastUpdate).toISOString().slice(0, 10); console.log(`${title}\t${author}\t${series}\t${pct}\tlast:${lastUpdate}`); } catch { // Item might have been removed } } } async function cmdFinished(args) { const limit = args[0] || '10'; const me = await api('/api/me'); const finished = (me.mediaProgress || []) .filter(p => p.isFinished) .sort((a, b) => (b.finishedAt || 0) - (a.finishedAt || 0)) .slice(0, parseInt(limit, 10)); if (finished.length === 0) { console.log('No finished books.'); return; } for (const p of finished) { try { const item = await api(`/api/items/${p.libraryItemId}?expanded=1`); const meta = item.media?.metadata || {}; const title = meta.title || 'Unknown'; const author = meta.authorName || 'Unknown'; const finishedDate = p.finishedAt ? new Date(p.finishedAt).toISOString().slice(0, 10) : '?'; console.log(`${title}\t${author}\tfinished:${finishedDate}`); } catch { // Item might have been removed } } } async function cmdStats() { const me = await api('/api/me'); const progress = me.mediaProgress || []; const inProgress = progress.filter(p => !p.isFinished && !p.hideFromContinueListening).length; const finished = progress.filter(p => p.isFinished).length; const totalListened = progress.reduce((sum, p) => sum + (p.currentTime || 0), 0); console.log(`in-progress:${inProgress}\tfinished:${finished}\ttotal-listened:${formatDuration(totalListened)}`); } const LIBRARY_ID = '197852a2-2602-4cb9-985c-70f9df5867a8'; async function cmdLibrary(args) { const full = args.includes('--full'); const limitArg = args.find(a => a !== '--full'); const endpoint = `/api/libraries/${LIBRARY_ID}/items?limit=0&sort=media.metadata.title`; const data = await api(endpoint); const items = data.results || []; const limit = limitArg ? parseInt(limitArg, 10) : items.length; console.log('TITLE\tAUTHOR\tSERIES\tNARRATOR\tGENRES\tDURATION\tYEAR\tDESCRIPTION'); for (const item of items.slice(0, limit)) { const meta = item.media?.metadata || {}; const title = meta.title || 'Unknown'; const author = meta.authorName || '-'; const series = meta.seriesName || '-'; const narrator = meta.narratorName || '-'; const genres = (meta.genres || []).join(',') || '-'; const duration = formatDuration(item.media?.duration || 0); const year = meta.publishedYear || '-'; const desc = meta.description || ''; const descOut = full ? desc.replace(/[\t\n\r]/g, ' ') : (desc.length > 100 ? desc.slice(0, 100).replace(/[\t\n\r]/g, ' ') + '...' : desc.replace(/[\t\n\r]/g, ' ')) || '-'; console.log(`${title}\t${author}\t${series}\t${narrator}\t${genres}\t${duration}\t${year}\t${descOut}`); } } async function cmdGenres() { const data = await api(`/api/libraries/${LIBRARY_ID}/items?limit=0`); const items = data.results || []; const counts = {}; for (const item of items) { for (const g of (item.media?.metadata?.genres || [])) { counts[g] = (counts[g] || 0) + 1; } } const sorted = Object.entries(counts).sort((a, b) => b[1] - a[1]); for (const [genre, count] of sorted) { console.log(`${genre}\t${count}`); } } // --- Main --- const [cmd, ...args] = process.argv.slice(2); const run = async () => { switch (cmd) { case 'current': await cmdCurrent(); break; case 'recent': await cmdRecent(args); break; case 'finished': await cmdFinished(args); break; case 'stats': await cmdStats(); break; case 'libraries': await cmdLibraries(); break; case 'library': await cmdLibrary(args); break; case 'genres': await cmdGenres(); break; default: console.log(`Usage: audiobooks Commands: current Currently listening / in-progress books recent [limit] Recently active books (default: 5) finished [limit] Finished books (default: 10) stats Listening stats overview libraries List libraries library [limit] All books with metadata (--full for full descriptions) genres List genres with counts`); } }; run().catch(err => { console.error(`Error: ${err.message}`); process.exit(1); });