From f1db2f3b2dd8cd5df05fd41e944fa21959a57540 Mon Sep 17 00:00:00 2001 From: Agent Date: Sat, 7 Feb 2026 16:09:01 +0000 Subject: [PATCH] Add Jellyfin CLI + update wind-down instructions with concrete suggestions --- HEARTBEAT.md | 6 ++ TOOLS.md | 22 +++++++ bin/jellyfin | 150 ++++++++++++++++++++++++++++++++++++++++++++++ memory/tasks.json | 7 +++ 4 files changed, 185 insertions(+) create mode 100755 bin/jellyfin diff --git a/HEARTBEAT.md b/HEARTBEAT.md index 829eda9..5926b26 100644 --- a/HEARTBEAT.md +++ b/HEARTBEAT.md @@ -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. + **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. **πŸ“ AUTO-LOG EVERYTHING 19:00β†’SLEEP** β€” Log to `memory/wind-down-log.json` as events happen: diff --git a/TOOLS.md b/TOOLS.md index d6a561d..ffc3932 100644 --- a/TOOLS.md +++ b/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 # 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 Helper script: `~/bin/forgejo` diff --git a/bin/jellyfin b/bin/jellyfin new file mode 100755 index 0000000..0b26870 --- /dev/null +++ b/bin/jellyfin @@ -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 '); 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 [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 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); }); diff --git a/memory/tasks.json b/memory/tasks.json index 792739a..b1b86a1 100644 --- a/memory/tasks.json +++ b/memory/tasks.json @@ -31,6 +31,13 @@ "text": "TYPO3 v13 upgrade for GBV", "priority": "soon", "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." } ] }