From 155e732e046cee4a04fc5cd70be10146fca4bb91 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 7 Sep 2026 01:35:12 +0000 Subject: [PATCH] argontv: the shared line, and whether there is room to sell Live TV passes are sold against one shared IPTV line, and nothing here could see it. Checking whether the line was healthy, how many of its connections were in use, or when it expired meant opening a browser and reading a panel, which is not a thing that can gate a sale. argontv status, expiry and free connections argontv slots [--json] free now; exit 3 when full argontv catalogue live / films / series counts argontv templates reseller templates (needs the account key) `slots` is the one with a job. A shared line permits a fixed number of simultaneous streams, so how many passes may exist is bounded by hardware rather than by demand -- sell one too many and the buyer who paid is the one told to come back later. The exit code is the interface: 0 there is room, 3 the line is full, 4 it could not be reached. A sale webhook checks that before taking money rather than discovering it afterwards. **Two APIs, kept apart on purpose.** The panel (player_api.php on the line's own server) answers about a line that exists and authenticates with the line's own username and password. The reseller API creates and extends lines, authenticates with an account-wide key, and spends money. Everything that reads is on the panel; everything that spends is on the reseller API, is marked as such, and refuses 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. **Sellable is not capacity.** The panel reports what the line permits; the tool reports one fewer as sellable, because selling to the full number means the first person to open a stream to check a complaint takes the slot a paying customer is using -- and the customer is the one who sees it fail. ARGONTV_MAX_PASSES overrides the reserve. Verified against a real line: 6 connections reported, 5 sellable, 1 held back, 30 days remaining, formats ts/m3u8/rtmp. Two things worth recording that came out of building it. The panel answers 200 with `auth: 0` for a wrong password rather than 401, so a check that only read the HTTP status would report a dead line as healthy with every field null; that is a refusal here. And `get_series` counts SHOWS while the M3U counts EPISODES, about twenty-six per show, which is why one line reports 44,790 series and its playlist has 1,152,848 entries for the same catalogue -- the numbers look like they disagree and do not. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0172JWTmsEEEcdAxPdn3UZnm --- bin/argontv.ts | 187 ++++++++++++++++++++++++++++++++++ src/argontv.ts | 234 +++++++++++++++++++++++++++++++++++++++++++ src/registry.ts | 1 + test/argontv.test.ts | 152 ++++++++++++++++++++++++++++ 4 files changed, 574 insertions(+) create mode 100644 bin/argontv.ts create mode 100644 src/argontv.ts create mode 100644 test/argontv.test.ts 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); + }); +});