249 lines
9.3 KiB
JavaScript
Executable file
249 lines
9.3 KiB
JavaScript
Executable file
#!/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 <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 || !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 <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); });
|