diff --git a/bin/argontv.ts b/bin/argontv.ts new file mode 100644 index 0000000..403692b --- /dev/null +++ b/bin/argontv.ts @@ -0,0 +1,187 @@ +#!/usr/bin/env node +/** + * argontv — the line behind the Live TV passes. + * + * argontv is the line healthy, and is there room to sell + * argontv status the same, spelled out + * argontv slots free connections right now, for a sale gate + * argontv slots --json the same, for a script or a webhook + * argontv catalogue how many live channels, films and series + * argontv templates the reseller's templates (needs the account key) + * + * `slots` is the one that matters when a pass is sold. A single shared line + * permits a fixed number of simultaneous streams, so how many passes may exist + * is bounded by that number rather than by demand: sell a seventh pass against + * six connections and the seventh buyer is told to come back later, having paid. + * + * Exit codes are the point of `slots`, so it can gate a sale from a shell: + * 0 there is room + * 3 the line is full + * 4 the line could not be reached or refused the credentials + */ + +import { + catalogue, + daysLeft, + lineFromEnv, + lineStatus, + reseller, + resellerKey, + slots, +} from '../src/argontv.ts'; +import { isMain } from '../src/is-main.ts'; + +const HELP = `argontv — the line behind the Live TV passes + + argontv [status] line health, expiry and free connections + argontv slots [--json] free connections now; exit 3 when full + argontv catalogue live / films / series counts + argontv templates reseller templates (needs IPTV_ARGON_API_KEY) + +Credentials, environment first then ~/.config/cli-tools/credentials.json: + ARGONTV_LINE_SERVER e.g. http://panel.example + ARGONTV_LINE_USERNAME + ARGONTV_LINE_PASSWORD + IPTV_ARGON_API_KEY reseller account key, for templates only +`; + +const NO_LINE = `argontv: no line configured. + +Set ARGONTV_LINE_SERVER, ARGONTV_LINE_USERNAME and ARGONTV_LINE_PASSWORD, or put +them in ~/.config/cli-tools/credentials.json. These are the line's own Xtream +credentials, the same pair a buyer would use in a player. +`; + +const fmtDate = (d: Date | null) => (d ? d.toISOString().replace('T', ' ').slice(0, 16) : 'unknown'); + +async function main(argv: string[]): Promise { + const args = argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(HELP); + return 0; + } + + const json = args.includes('--json'); + const command = args.find((a) => !a.startsWith('-')) ?? 'status'; + + if (command === 'templates') { + const key = resellerKey(); + if (!key) { + process.stderr.write( + 'argontv: no reseller key. Set IPTV_ARGON_API_KEY.\n' + + 'It is issued by distributors.argontv.nl and cannot be generated locally.\n', + ); + return 4; + } + try { + const data = (await reseller('/api/v1/templates', { key })) as { templates?: unknown[] }; + const list = data.templates ?? []; + if (json) { + process.stdout.write(`${JSON.stringify(list, null, 2)}\n`); + return 0; + } + if (list.length === 0) { + process.stdout.write('No templates on that account.\n'); + return 0; + } + for (const t of list as Array>) { + process.stdout.write(`${String(t.id ?? '?').padStart(6)} ${String(t.name ?? '')}\n`); + } + return 0; + } catch (error) { + process.stderr.write(`argontv: ${(error as Error).message}\n`); + return 4; + } + } + + const line = lineFromEnv(); + if (!line) { + process.stderr.write(NO_LINE); + return 4; + } + + let status: Awaited>; + try { + status = await lineStatus(line); + } catch (error) { + process.stderr.write(`argontv: ${(error as Error).message}\n`); + return 4; + } + + const room = slots(status); + + if (command === 'slots') { + if (json) { + process.stdout.write(`${JSON.stringify({ ...room, status: status.status })}\n`); + } else { + process.stdout.write( + `${room.free ?? '?'} of ${room.capacity ?? '?'} connections free` + + `${room.sellable === null ? '' : `, ${room.sellable} passes sellable`}\n`, + ); + } + // The gate. A caller selling a pass checks this exit code, not the text. + return room.free !== null && room.free <= 0 ? 3 : 0; + } + + if (command === 'catalogue') { + const c = await catalogue(line); + if (json) { + process.stdout.write(`${JSON.stringify(c)}\n`); + return 0; + } + process.stdout.write( + `live ${c.live.toLocaleString('en-US').padStart(10)}\n` + + `films ${c.movies.toLocaleString('en-US').padStart(10)}\n` + + `series ${c.series.toLocaleString('en-US').padStart(10)} (shows, not episodes)\n`, + ); + return 0; + } + + if (command !== 'status') { + process.stderr.write(`argontv: unknown command "${command}"\n\n${HELP}`); + return 2; + } + + const days = daysLeft(status.expiresAt); + if (json) { + process.stdout.write( + `${JSON.stringify({ ...status, ...room, daysLeft: days }, null, 2)}\n`, + ); + return 0; + } + + process.stdout.write( + `status ${status.status}${status.isTrial ? ' (trial)' : ''}\n` + + `connections ${room.free ?? '?'} free of ${room.capacity ?? '?'}\n` + + `sellable ${room.sellable ?? '?'} passes` + + `${room.reserved > 0 ? ` (${room.reserved} connection held back)` : ''}\n` + + `expires ${fmtDate(status.expiresAt)}${days === null ? '' : ` (${days} days)`}\n` + + `formats ${status.formats.join(', ') || 'unknown'}\n`, + ); + + // Said out loud rather than left to be inferred from the numbers: the whole + // product is bounded by this, and it is the number people forget. + if (room.capacity !== null && room.sellable !== null) { + process.stdout.write( + `\nAt most ${room.capacity} streams at once across every site, so ${room.sellable} active passes` + + `${room.reserved > 0 ? ` and ${room.reserved} spare for us` : ''}.\n`, + ); + } + if (days !== null && days <= 7) { + process.stdout.write(`Warning: this line expires in ${days} days.\n`); + } + return 0; +} + +if (isMain(import.meta.url)) { + main(process.argv) + .then((code) => { + process.exitCode = code; + }) + .catch((error) => { + process.stderr.write(`argontv: ${(error as Error).message}\n`); + process.exitCode = 1; + }); +} + +export { main }; diff --git a/src/argontv.ts b/src/argontv.ts new file mode 100644 index 0000000..4bb3bb5 --- /dev/null +++ b/src/argontv.ts @@ -0,0 +1,234 @@ +/** + * ArgonTV lines, from the shell. + * + * Two different APIs sit behind this, and keeping them apart is most of the + * design: + * + * The PANEL (`player_api.php` on the line's own server) answers about a line + * that already exists -- is it active, how many of its connections are in use + * right now, when does it expire, what is in its catalogue. It authenticates + * with the line's own username and password, which is the credential a buyer + * would hold. + * + * The RESELLER API (distributors.argontv.nl) is how a line is created and + * extended. It authenticates with an account-wide key, and that key can do + * things that cost money. + * + * Everything that reads is on the panel. Everything that spends is on the + * reseller API, is marked as such, and refuses to run without an explicit key. + * A tool that could quietly provision a line because a variable happened to be + * set is a tool that bills you for a typo. + */ + +import { loadStored } from './credentials.ts'; + +export const PANEL_TIMEOUT_MS = 60_000; + +/** The reseller host. `api.argontv.nl` has no DNS record; this one answers. */ +export const RESELLER_BASE = 'https://distributors.argontv.nl'; + +export interface LineCreds { + server: string; + username: string; + password: string; +} + +export interface LineStatus { + status: string; + maxConnections: number | null; + activeConnections: number | null; + expiresAt: Date | null; + createdAt: Date | null; + isTrial: boolean; + formats: string[]; +} + +/** + * The line to ask about. + * + * Environment first, then the stored credentials, matching every other command + * here. Returns null rather than throwing so a caller can print usage instead of + * a stack trace, which is what somebody running this for the first time needs. + */ +export function lineFromEnv(env: NodeJS.ProcessEnv = process.env): LineCreds | null { + const stored = loadStored(env); + const pick = (variable: string) => env[variable] ?? stored[variable] ?? ''; + const server = pick('ARGONTV_LINE_SERVER'); + const username = pick('ARGONTV_LINE_USERNAME'); + const password = pick('ARGONTV_LINE_PASSWORD'); + if (!server || !username || !password) return null; + return { server: server.replace(/\/$/, ''), username, password }; +} + +const panelUrl = (line: LineCreds, action?: string): string => { + const q = new URLSearchParams({ username: line.username, password: line.password }); + if (action) q.set('action', action); + return `${line.server}/player_api.php?${q}`; +}; + +async function panel(line: LineCreds, action?: string, fetchImpl: typeof fetch = fetch) { + const res = await fetchImpl(panelUrl(line, action), { + // Panels routinely refuse a generic client. This is what a set-top box sends. + headers: { 'user-agent': 'VLC/3.0.20 LibVLC/3.0.20' }, + signal: AbortSignal.timeout(PANEL_TIMEOUT_MS), + }); + const text = await res.text(); + if (!res.ok) throw new Error(`panel answered ${res.status}`); + try { + return JSON.parse(text); + } catch { + throw new Error(`panel did not answer JSON (${text.slice(0, 60)})`); + } +} + +/** Seconds-since-epoch as a string, which is how a panel spells every date. */ +const epoch = (v: unknown): Date | null => { + const n = Number(v); + return Number.isFinite(n) && n > 0 ? new Date(n * 1000) : null; +}; + +const int = (v: unknown): number | null => { + const n = Number(v); + return Number.isFinite(n) ? n : null; +}; + +export async function lineStatus( + line: LineCreds, + fetchImpl: typeof fetch = fetch, +): Promise { + const data = await panel(line, undefined, fetchImpl); + const info = data?.user_info ?? {}; + if (info.auth !== 1 && info.auth !== '1') { + throw new Error('the panel rejected that username and password'); + } + return { + status: String(info.status ?? 'unknown'), + maxConnections: int(info.max_connections), + activeConnections: int(info.active_cons), + expiresAt: epoch(info.exp_date), + createdAt: epoch(info.created_at), + isTrial: String(info.is_trial ?? '0') === '1', + formats: Array.isArray(info.allowed_output_formats) ? info.allowed_output_formats : [], + }; +} + +export interface Catalogue { + live: number; + movies: number; + series: number; +} + +/** + * How big the catalogue is, per kind. + * + * Asked of the panel rather than by counting an M3U, and the difference is not + * cosmetic: the full playlist for this line is 583MB and 1.4 million entries, + * because it expands every episode of every series. The panel answers the same + * question in three small requests. + * + * Note that `series` here counts SHOWS. The M3U counts episodes, and there are + * about twenty-six of those per show -- which is why the two numbers look like + * they disagree and do not. + */ +export async function catalogue( + line: LineCreds, + fetchImpl: typeof fetch = fetch, +): Promise { + const [live, movies, series] = await Promise.all([ + panel(line, 'get_live_streams', fetchImpl), + panel(line, 'get_vod_streams', fetchImpl), + panel(line, 'get_series', fetchImpl), + ]); + const n = (v: unknown) => (Array.isArray(v) ? v.length : 0); + return { live: n(live), movies: n(movies), series: n(series) }; +} + +/** + * Room on the line, and room to sell -- which are two different numbers. + * + * `capacity` is what the panel permits and `free` is what is unused this second. + * Neither is how many passes should exist, and conflating them is the mistake + * this separation exists to prevent: sell to the full capacity and the first + * person to open a stream for testing, or to check a complaint, takes the slot a + * paying customer was about to use. + * + * `sellable` is therefore capacity less a reserved slot, and it is the number + * that should bound active passes. One in reserve is not caution for its own + * sake -- it is the difference between diagnosing a stream problem and having to + * choose between diagnosing it and a customer watching. + * + * ARGONTV_MAX_PASSES overrides it outright for a line whose reserve should be + * larger, or none at all. + */ +export function slots( + status: LineStatus, + env: NodeJS.ProcessEnv = process.env, +): { capacity: number | null; free: number | null; sellable: number | null; reserved: number } { + const capacity = status.maxConnections; + const used = status.activeConnections ?? 0; + const free = capacity === null ? null : Math.max(0, capacity - used); + + const override = Number(env.ARGONTV_MAX_PASSES); + const sellable = + Number.isFinite(override) && override > 0 + ? Math.floor(override) + : capacity === null + ? null + : Math.max(1, capacity - 1); + + return { + capacity, + free, + sellable, + reserved: capacity === null || sellable === null ? 0 : Math.max(0, capacity - sellable), + }; +} + +export const daysLeft = (at: Date | null, now: Date = new Date()): number | null => + at ? Math.floor((at.getTime() - now.getTime()) / 86_400_000) : null; + +/* ------------------------------------------------------------- reseller --- */ + +/** + * The account-wide key, and nothing that guesses at one. + * + * Read only when a command that spends money asks for it, so that a plain + * `argontv status` never touches it. Absent is a refusal with an explanation + * rather than a stack trace: the key comes from the reseller panel and cannot be + * generated locally, which is the one thing somebody hitting this needs told. + */ +export function resellerKey(env: NodeJS.ProcessEnv = process.env): string | null { + return env.IPTV_ARGON_API_KEY ?? loadStored(env).IPTV_ARGON_API_KEY ?? null; +} + +export async function reseller( + path: string, + { method = 'GET', body, key, fetchImpl = fetch }: { + method?: string; + body?: unknown; + key: string; + fetchImpl?: typeof fetch; + }, +) { + const res = await fetchImpl(`${RESELLER_BASE}${path}`, { + method, + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${key}`, + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); + const data = await res.json().catch(() => ({})); + if (res.status === 401) { + throw new Error( + 'the reseller key was rejected (401). It comes from distributors.argontv.nl and cannot be generated here.', + ); + } + if (!res.ok) throw new Error(`reseller answered ${res.status}`); + if ((data as { error?: boolean }).error === true) { + throw new Error(`reseller refused: ${(data as { err?: string }).err ?? 'no reason given'}`); + } + return data; +} diff --git a/src/registry.ts b/src/registry.ts index fc5fa96..df9c600 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -28,6 +28,7 @@ export interface Command { /** One-line summaries, so `cli-tools list` says what each command is for. */ const SUMMARIES: Record = { affiliate: 'Work through a list of programs you mean to sign up for', + argontv: 'The shared IPTV line: is it healthy, and is there room to sell another pass', 'ask-web': 'Answer a question from the live web, with its sources', 'blog-post': 'Publish to a plain-HTML blog without breaking the feed', cal: 'The calendar from the terminal, over CalDAV: agenda, one event, add, remove', diff --git a/test/argontv.test.ts b/test/argontv.test.ts new file mode 100644 index 0000000..236b9a9 --- /dev/null +++ b/test/argontv.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from 'vitest'; +import { catalogue, daysLeft, lineFromEnv, lineStatus, slots } from '../src/argontv.ts'; +import type { LineStatus } from '../src/argontv.ts'; + +/** + * The line behind the Live TV passes. + * + * The arithmetic here is the product: one shared line permits a fixed number of + * simultaneous streams, so how many passes may be sold is bounded by hardware + * rather than by demand. Getting it wrong sells somebody a month they cannot + * watch. + */ + +const status = (over: Partial = {}): LineStatus => ({ + status: 'Active', + maxConnections: 6, + activeConnections: 0, + expiresAt: new Date('2026-10-07T00:04:10Z'), + createdAt: new Date('2026-09-06T14:04:10Z'), + isTrial: false, + formats: ['ts', 'm3u8', 'rtmp'], + ...over, +}); + +describe('how many passes may exist', () => { + /* + * The reserve is the point. Sold to the full six, the first person to open a + * stream to check a complaint takes the slot a paying customer wanted -- and + * the customer is the one who sees it fail. + */ + it('holds one connection back from the sellable count', () => { + const room = slots(status(), {}); + expect(room.capacity).toBe(6); + expect(room.sellable).toBe(5); + expect(room.reserved).toBe(1); + }); + + it('free counts what is unused right now, not what is sellable', () => { + const room = slots(status({ activeConnections: 4 }), {}); + expect(room.free).toBe(2); + // Still five: how many passes may exist does not change because four people + // happen to be watching this second. + expect(room.sellable).toBe(5); + }); + + it('never reports negative headroom when the panel over-counts', () => { + // Panels do report more active connections than the line permits, briefly, + // while a stream is being torn down. + expect(slots(status({ activeConnections: 9 }), {}).free).toBe(0); + }); + + it('an explicit cap overrides the reserve', () => { + const room = slots(status(), { ARGONTV_MAX_PASSES: '3' }); + expect(room.sellable).toBe(3); + expect(room.reserved).toBe(3); + }); + + it('a one-connection line is still sellable once', () => { + const room = slots(status({ maxConnections: 1 }), {}); + expect(room.sellable).toBe(1); + expect(room.reserved).toBe(0); + }); + + it('says nothing rather than guessing when the panel omits the limit', () => { + const room = slots(status({ maxConnections: null }), {}); + expect(room.capacity).toBeNull(); + expect(room.free).toBeNull(); + expect(room.sellable).toBeNull(); + }); +}); + +describe('reading the panel', () => { + const answer = (body: unknown) => + (async () => new Response(JSON.stringify(body), { status: 200 })) as unknown as typeof fetch; + + const line = { server: 'http://panel.test', username: 'u', password: 'p' }; + + it('reads the account, converting the panel’s epoch strings', async () => { + const s = await lineStatus( + line, + answer({ + user_info: { + auth: 1, + status: 'Active', + max_connections: '6', + active_cons: '2', + exp_date: '1791331450', + is_trial: '0', + allowed_output_formats: ['ts'], + }, + }), + ); + expect(s.maxConnections).toBe(6); + expect(s.activeConnections).toBe(2); + expect(s.expiresAt?.toISOString()).toBe('2026-10-07T00:04:10.000Z'); + expect(s.isTrial).toBe(false); + }); + + /* + * A panel answers 200 with `auth: 0` for a wrong password rather than 401, so + * a status check that only looked at the HTTP code would report a dead line as + * healthy with every field null. + */ + it('treats auth: 0 as a refusal, not as an empty account', async () => { + await expect(lineStatus(line, answer({ user_info: { auth: 0 } }))).rejects.toThrow( + /rejected that username and password/, + ); + }); + + /* + * `series` from the panel counts SHOWS. The M3U expands every episode, about + * twenty-six per show, which is why one line reports 44,790 and its playlist + * has 1,152,848 entries for the same catalogue. + */ + it('counts each kind separately', async () => { + const c = await catalogue(line, (async (url: string | URL) => { + const action = String(url).match(/action=(\w+)/)?.[1]; + const n = { get_live_streams: 3, get_vod_streams: 2, get_series: 1 }[action ?? ''] ?? 0; + return new Response(JSON.stringify(Array.from({ length: n }, (_, i) => i)), { status: 200 }); + }) as unknown as typeof fetch); + expect(c).toEqual({ live: 3, movies: 2, series: 1 }); + }); +}); + +describe('configuration', () => { + it('prefers the environment over the stored file', () => { + const found = lineFromEnv({ + ARGONTV_LINE_SERVER: 'http://a.test/', + ARGONTV_LINE_USERNAME: 'u', + ARGONTV_LINE_PASSWORD: 'p', + } as NodeJS.ProcessEnv); + expect(found).toEqual({ server: 'http://a.test', username: 'u', password: 'p' }); + }); + + it('is null when half-configured, so the caller can print usage', () => { + expect( + lineFromEnv({ ARGONTV_LINE_SERVER: 'http://a.test' } as NodeJS.ProcessEnv), + ).toBeNull(); + }); +}); + +describe('expiry', () => { + it('counts whole days remaining', () => { + expect(daysLeft(new Date('2026-10-07T00:00:00Z'), new Date('2026-09-07T00:00:00Z'))).toBe(30); + }); + + it('goes negative once it has lapsed, rather than clamping to zero', () => { + // A lapsed line should read as lapsed. Zero would be indistinguishable from + // "expires today", which is a different thing to do about. + expect(daysLeft(new Date('2026-09-01T00:00:00Z'), new Date('2026-09-06T00:00:00Z'))).toBe(-5); + }); +});