Add audiobooks CLI for Audiobookshelf API access
- current/recent/finished/stats/libraries commands - Tab-separated output for minimal tokens - For wind-down suggestions: know what user is listening to
This commit is contained in:
parent
10a4c1227b
commit
cc5baa712b
2 changed files with 188 additions and 0 deletions
19
TOOLS.md
19
TOOLS.md
|
|
@ -67,6 +67,25 @@ derstandard raw [max] # Full RSS XML
|
||||||
2. Pick interesting ones, optionally fetch full content with `articles`
|
2. Pick interesting ones, optionally fetch full content with `articles`
|
||||||
3. Next briefing: only shows articles published since last check
|
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
|
## Forgejo Git Access
|
||||||
|
|
||||||
Helper script: `~/bin/forgejo`
|
Helper script: `~/bin/forgejo`
|
||||||
|
|
|
||||||
169
bin/audiobooks
Executable file
169
bin/audiobooks
Executable file
|
|
@ -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 <command>
|
||||||
|
|
||||||
|
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); });
|
||||||
Loading…
Add table
Add a link
Reference in a new issue