Add Jellyfin CLI + update wind-down instructions with concrete suggestions
This commit is contained in:
parent
4be80803d4
commit
f1db2f3b2d
4 changed files with 185 additions and 0 deletions
|
|
@ -20,6 +20,12 @@ Check the following and notify only once per event (track in `memory/heartbeat-s
|
||||||
|
|
||||||
**If they're about to start something new after 20:00**: Gently suggest postponing to tomorrow.
|
**If they're about to start something new after 20:00**: Gently suggest postponing to tomorrow.
|
||||||
|
|
||||||
|
**Concrete wind-down suggestions**: Don't just say "maybe wind down" — use real data:
|
||||||
|
- `bin/jellyfin resume` → suggest continuing what they're watching
|
||||||
|
- `bin/jellyfin recent` → suggest something newly added
|
||||||
|
- `bin/audiobooks current` → suggest picking up their audiobook
|
||||||
|
- Make it specific: "You're 44% through Die zweite Legion" or "New movie added: The Rip (2026)"
|
||||||
|
|
||||||
**Work-end reminders**: When they indicate they're wrapping up or transitioning to wind-down, check `memory/tasks.json` for `recurring` items with `when: "evening"` and remind them (e.g., nose shower) before they get too deep into relaxation mode.
|
**Work-end reminders**: When they indicate they're wrapping up or transitioning to wind-down, check `memory/tasks.json` for `recurring` items with `when: "evening"` and remind them (e.g., nose shower) before they get too deep into relaxation mode.
|
||||||
|
|
||||||
**📝 AUTO-LOG EVERYTHING 19:00→SLEEP** — Log to `memory/wind-down-log.json` as events happen:
|
**📝 AUTO-LOG EVERYTHING 19:00→SLEEP** — Log to `memory/wind-down-log.json` as events happen:
|
||||||
|
|
|
||||||
22
TOOLS.md
22
TOOLS.md
|
|
@ -86,6 +86,28 @@ audiobooks libraries # List libraries
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Jellyfin
|
||||||
|
|
||||||
|
Helper script: `~/clawd/bin/jellyfin`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
jellyfin resume [limit] # Continue watching (in-progress items)
|
||||||
|
jellyfin recent [limit] # Recently added to library
|
||||||
|
jellyfin watched [limit] # Watch history (last played)
|
||||||
|
jellyfin shows # All series with watch status
|
||||||
|
jellyfin movies # All movies with watch status
|
||||||
|
jellyfin search <query> # Search library
|
||||||
|
jellyfin stats # Watch statistics overview
|
||||||
|
jellyfin libraries # List libraries
|
||||||
|
```
|
||||||
|
|
||||||
|
- Credentials: `.credentials/jellyfin.env` (user-scoped token for `tv`)
|
||||||
|
- URL: `https://jellyfin.cloonar.com`
|
||||||
|
- Output is tab-separated for minimal tokens
|
||||||
|
- Use during wind-down to suggest specific shows/movies
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Forgejo Git Access
|
## Forgejo Git Access
|
||||||
|
|
||||||
Helper script: `~/bin/forgejo`
|
Helper script: `~/bin/forgejo`
|
||||||
|
|
|
||||||
150
bin/jellyfin
Executable file
150
bin/jellyfin
Executable file
|
|
@ -0,0 +1,150 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
// Jellyfin CLI — token-efficient output for assistant use
|
||||||
|
const https = require('https');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Load credentials
|
||||||
|
const envFile = fs.readFileSync(path.join(__dirname, '..', '.credentials', 'jellyfin.env'), 'utf8');
|
||||||
|
const env = {};
|
||||||
|
envFile.split('\n').forEach(l => { const [k, ...v] = l.split('='); if (k && v.length) env[k.trim()] = v.join('=').trim(); });
|
||||||
|
|
||||||
|
const BASE = env.JELLYFIN_URL;
|
||||||
|
const API_KEY = env.JELLYFIN_API_KEY;
|
||||||
|
const USER_ID = env.JELLYFIN_USER_ID;
|
||||||
|
|
||||||
|
function api(endpoint) {
|
||||||
|
const sep = endpoint.includes('?') ? '&' : '?';
|
||||||
|
const url = `${BASE}${endpoint}${sep}api_key=${API_KEY}`;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(url, res => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', c => data += c);
|
||||||
|
res.on('end', () => {
|
||||||
|
try { resolve(JSON.parse(data)); } catch { resolve(data); }
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserId() { return USER_ID; }
|
||||||
|
|
||||||
|
function dur(ticks) {
|
||||||
|
if (!ticks) return '';
|
||||||
|
const min = Math.round(ticks / 600000000);
|
||||||
|
if (min < 60) return `${min}m`;
|
||||||
|
return `${Math.floor(min/60)}h${min%60 ? ' '+min%60+'m' : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(userData, runTimeTicks) {
|
||||||
|
if (!userData) return '';
|
||||||
|
if (userData.Played) return '✓';
|
||||||
|
if (userData.PlayedPercentage) return Math.round(userData.PlayedPercentage) + '%';
|
||||||
|
if (userData.PlaybackPositionTicks && runTimeTicks) return Math.round(userData.PlaybackPositionTicks / runTimeTicks * 100) + '%';
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands = {
|
||||||
|
async resume(limit = 10) {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items/Resume?Limit=${limit}&Fields=Overview,RunTimeTicks&MediaTypes=Video`);
|
||||||
|
if (!r.Items.length) return console.log('Nothing in progress');
|
||||||
|
console.log('TITLE\tEPISODE\tPROGRESS\tDURATION');
|
||||||
|
r.Items.forEach(i => {
|
||||||
|
const show = i.SeriesName || '';
|
||||||
|
const name = show ? `${i.Name}` : i.Name;
|
||||||
|
console.log([show || i.Name, show ? i.Name : '', pct(i.UserData, i.RunTimeTicks), dur(i.RunTimeTicks)].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async recent(limit = 10) {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items/Latest?Limit=${limit}&Fields=RunTimeTicks`);
|
||||||
|
if (!r.length) return console.log('No recent items');
|
||||||
|
console.log('TYPE\tTITLE\tNAME\tYEAR\tDURATION');
|
||||||
|
r.forEach(i => {
|
||||||
|
console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || '', dur(i.RunTimeTicks)].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async watched(limit = 20) {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items?Limit=${limit}&SortBy=DatePlayed&SortOrder=Descending&Filters=IsPlayed&Recursive=true&IncludeItemTypes=Movie,Episode&Fields=RunTimeTicks,DateLastMediaAdded`);
|
||||||
|
if (!r.Items.length) return console.log('No watched items');
|
||||||
|
console.log('TYPE\tTITLE\tEPISODE\tYEAR');
|
||||||
|
r.Items.forEach(i => {
|
||||||
|
console.log([i.Type === 'Episode' ? 'EP' : 'MOV', i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async shows() {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Series&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`);
|
||||||
|
if (!r.Items.length) return console.log('No shows');
|
||||||
|
console.log('TITLE\tYEAR\tSTATUS');
|
||||||
|
r.Items.forEach(i => {
|
||||||
|
console.log([i.Name, i.ProductionYear || '', pct(i.UserData, i.RunTimeTicks) || 'unwatched'].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async movies() {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items?IncludeItemTypes=Movie&Recursive=true&SortBy=SortName&Fields=RunTimeTicks`);
|
||||||
|
if (!r.Items.length) return console.log('No movies');
|
||||||
|
console.log('TITLE\tYEAR\tDURATION\tSTATUS');
|
||||||
|
r.Items.forEach(i => {
|
||||||
|
console.log([i.Name, i.ProductionYear || '', dur(i.RunTimeTicks), pct(i.UserData, i.RunTimeTicks) || '-'].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
if (!query) { console.error('Usage: jellyfin search <query>'); process.exit(1); }
|
||||||
|
const uid = await getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Items?SearchTerm=${encodeURIComponent(query)}&Recursive=true&IncludeItemTypes=Movie,Series,Episode&Limit=10&Fields=RunTimeTicks`);
|
||||||
|
if (!r.Items.length) return console.log('No results');
|
||||||
|
console.log('TYPE\tTITLE\tNAME\tYEAR');
|
||||||
|
r.Items.forEach(i => {
|
||||||
|
console.log([i.Type, i.SeriesName || i.Name, i.SeriesName ? i.Name : '', i.ProductionYear || ''].join('\t'));
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async stats() {
|
||||||
|
const uid = await getUserId();
|
||||||
|
const [movies, shows, episodes] = await Promise.all([
|
||||||
|
api(`/Users/${uid}/Items?IncludeItemTypes=Movie&Recursive=true&Filters=IsPlayed`),
|
||||||
|
api(`/Users/${uid}/Items?IncludeItemTypes=Series&Recursive=true`),
|
||||||
|
api(`/Users/${uid}/Items?IncludeItemTypes=Episode&Recursive=true&Filters=IsPlayed`),
|
||||||
|
]);
|
||||||
|
console.log(`Movies watched:\t${movies.TotalRecordCount}`);
|
||||||
|
console.log(`Shows in library:\t${shows.TotalRecordCount}`);
|
||||||
|
console.log(`Episodes watched:\t${episodes.TotalRecordCount}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async libraries() {
|
||||||
|
const uid = getUserId();
|
||||||
|
const r = await api(`/Users/${uid}/Views`);
|
||||||
|
console.log('NAME\tTYPE\tID');
|
||||||
|
(r.Items || []).forEach(l => {
|
||||||
|
console.log([l.Name, l.CollectionType || 'mixed', l.Id].join('\t'));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const cmd = process.argv[2];
|
||||||
|
const args = process.argv.slice(3);
|
||||||
|
|
||||||
|
if (!cmd || !commands[cmd]) {
|
||||||
|
console.log('Usage: jellyfin <command> [args]\n');
|
||||||
|
console.log('Commands:');
|
||||||
|
console.log(' resume [limit] Continue watching');
|
||||||
|
console.log(' recent [limit] Recently added');
|
||||||
|
console.log(' watched [limit] Watch history');
|
||||||
|
console.log(' shows All series');
|
||||||
|
console.log(' movies All movies');
|
||||||
|
console.log(' search <query> Search library');
|
||||||
|
console.log(' stats Watch statistics');
|
||||||
|
console.log(' libraries List libraries');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
commands[cmd](...args).catch(e => { console.error(e.message); process.exit(1); });
|
||||||
|
|
@ -31,6 +31,13 @@
|
||||||
"text": "TYPO3 v13 upgrade for GBV",
|
"text": "TYPO3 v13 upgrade for GBV",
|
||||||
"priority": "soon",
|
"priority": "soon",
|
||||||
"lastNudged": "2026-02-07T14:11:07.284Z"
|
"lastNudged": "2026-02-07T14:11:07.284Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "47eaa129",
|
||||||
|
"added": "2026-02-07",
|
||||||
|
"text": "Look into Jellyfin playing stats with Intune app on iOS",
|
||||||
|
"priority": "soon",
|
||||||
|
"context": "Goal: give Hoid access to TV show watching stats for better wind-down suggestions. Check if Intune/Jellyfin exposes an API for currently watching/recently played."
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue