diff --git a/TOOLS.md b/TOOLS.md index 16f0c73..d6a561d 100644 --- a/TOOLS.md +++ b/TOOLS.md @@ -67,6 +67,25 @@ derstandard raw [max] # Full RSS XML 2. Pick interesting ones, optionally fetch full content with `articles` 3. Next briefing: only shows articles published since last check +## Audiobookshelf + +Helper script: `~/clawd/bin/audiobooks` + +```bash +audiobooks current # Currently listening / in-progress books +audiobooks recent [limit] # Recently active books (default: 5) +audiobooks finished [limit] # Finished books (default: 10) +audiobooks stats # Listening stats overview +audiobooks libraries # List libraries +``` + +- Credentials: `.credentials/audiobookshelf.env` +- URL: `https://audiobooks.cloonar.com` +- Output is tab-separated for minimal tokens +- Use during wind-down to suggest continuing audiobook + +--- + ## Forgejo Git Access Helper script: `~/bin/forgejo` diff --git a/bin/audiobooks b/bin/audiobooks new file mode 100755 index 0000000..a46c99c --- /dev/null +++ b/bin/audiobooks @@ -0,0 +1,169 @@ +#!/usr/bin/env node + +const fs = require('fs'); +const path = require('path'); +const https = require('https'); + +const CRED_PATH = path.join(__dirname, '..', '.credentials', 'audiobookshelf.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)}`); +} + +// --- 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; + 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`); + } +}; + +run().catch(err => { console.error(`Error: ${err.message}`); process.exit(1); });