#!/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 from unified services.env const credFile = process.env.CRED_FILE || path.join(__dirname, '..', '.credentials', 'services.env'); const envFile = fs.readFileSync(credFile, 'utf8'); const env = {}; envFile.split('\n').forEach(l => { const m = l.match(/^(\w+)=(.+)$/); if (m) env[m[1]] = m[2]; }); const BASE = env.JELLYFIN_URL; const TOKEN_CACHE = path.join(__dirname, '..', '.credentials', '.jellyfin-token.json'); function request(method, endpoint, body) { return new Promise((resolve, reject) => { const sep = endpoint.includes('?') ? '&' : '?'; const u = new URL(`${BASE}${endpoint}`); const opts = { hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, method, headers: {} }; if (body) { const data = JSON.stringify(body); opts.headers['Content-Type'] = 'application/json'; opts.headers['Content-Length'] = Buffer.byteLength(data); } const req = https.request(opts, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, data: d }); } }); }); req.on('error', reject); if (body) req.write(JSON.stringify(body)); req.end(); }); } async function authenticate() { // Check cache first try { const cached = JSON.parse(fs.readFileSync(TOKEN_CACHE, 'utf8')); if (cached.token && cached.userId) { // Verify token still works const test = await apiWith(cached.token, cached.userId, `/Users/${cached.userId}`); if (test) return cached; } } catch {} // Authenticate const res = await request('POST', '/Users/AuthenticateByName', { Username: env.JELLYFIN_USER, Pw: env.JELLYFIN_PASS }); // Add auth header for the request const authRes = await new Promise((resolve, reject) => { const body = JSON.stringify({ Username: env.JELLYFIN_USER, Pw: env.JELLYFIN_PASS }); const u = new URL(`${BASE}/Users/AuthenticateByName`); const req = https.request({ hostname: u.hostname, port: u.port || 443, path: u.pathname, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'X-Emby-Authorization': 'MediaBrowser Client="Hoid", Device="CLI", DeviceId="hoid-cli-1", Version="1.0"' } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(d) }); } catch { resolve({ status: res.statusCode, data: d }); } }); }); req.on('error', reject); req.write(body); req.end(); }); if (authRes.status !== 200 || !authRes.data.AccessToken) { console.error(`Auth failed (${authRes.status}):`, typeof authRes.data === 'string' ? authRes.data : JSON.stringify(authRes.data).slice(0, 200)); process.exit(1); } const result = { token: authRes.data.AccessToken, userId: authRes.data.User.Id }; fs.writeFileSync(TOKEN_CACHE, JSON.stringify(result)); return result; } function apiWith(token, userId, endpoint) { const sep = endpoint.includes('?') ? '&' : '?'; const url = `${BASE}${endpoint}`; return new Promise((resolve, reject) => { const u = new URL(url); https.get({ hostname: u.hostname, port: u.port || 443, path: u.pathname + u.search, headers: { 'X-Emby-Token': token } }, res => { let data = ''; res.on('data', c => data += c); res.on('end', () => { if (res.statusCode === 401) return resolve(null); try { resolve(JSON.parse(data)); } catch { resolve(data); } }); }).on('error', reject); }); } let _auth; async function api(endpoint) { if (!_auth) _auth = await authenticate(); const result = await apiWith(_auth.token, _auth.userId, endpoint); if (result === null) { // Token expired, re-auth try { fs.unlinkSync(TOKEN_CACHE); } catch {} _auth = await authenticate(); return apiWith(_auth.token, _auth.userId, endpoint); } return result; } async function getUserId() { if (!_auth) _auth = await authenticate(); return _auth.userId; } 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 || !r.Items.length) return console.log('Nothing in progress'); console.log('TITLE\tEPISODE\tPROGRESS\tDURATION'); r.Items.forEach(i => { const show = i.SeriesName || ''; 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 || !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 || !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 || !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 || !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 || !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 = await 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); });