diff --git a/.env.example b/.env.example
index 24aa0cc..7aea2eb 100644
--- a/.env.example
+++ b/.env.example
@@ -6,3 +6,10 @@ SMTP_PASS=your-mailbox-password
# Admin email to receive access request notifications
ADMIN_EMAIL=jeff@jeffemmett.com
+
+# Channel approval token signing secret (generate with: openssl rand -hex 32)
+TOKEN_SECRET=your-random-secret-here
+
+# Threadfin credentials for one-click channel activation
+THREADFIN_USER=your-threadfin-username
+THREADFIN_PASS=your-threadfin-password
diff --git a/app/api/approve-channels/route.ts b/app/api/approve-channels/route.ts
new file mode 100644
index 0000000..3b34cd8
--- /dev/null
+++ b/app/api/approve-channels/route.ts
@@ -0,0 +1,163 @@
+import { NextRequest } from 'next/server'
+import nodemailer from 'nodemailer'
+import { verifyApproveToken } from '@/lib/token'
+import { activateChannels } from '@/lib/threadfin'
+
+function htmlPage(title: string, body: string): Response {
+ return new Response(
+ `
+
+
+
+
+ ${title} — Jefflix
+
+
+
+
+ ${body}
+
+
+`,
+ { status: 200, headers: { 'Content-Type': 'text/html; charset=utf-8' } },
+ )
+}
+
+export async function GET(request: NextRequest) {
+ const token = request.nextUrl.searchParams.get('token')
+
+ if (!token) {
+ return htmlPage('Invalid Link', `
+ ❌
+ Invalid Link
+ No approval token provided.
+ `)
+ }
+
+ const payload = verifyApproveToken(token)
+ if (!payload) {
+ return htmlPage('Expired or Invalid', `
+ ⏳
+ Link Expired or Invalid
+ This approval link has expired or is invalid. Channel requests expire after 7 days.
+ Ask the user to submit a new request.
+ `)
+ }
+
+ try {
+ const result = await activateChannels(
+ payload.channels.map((ch) => ch.id),
+ )
+
+ // Send confirmation email to the requester
+ await sendConfirmationEmail(payload.email, result.activated, result.notFound, payload.channels)
+
+ const activatedHtml = result.activated.length > 0
+ ? `Activated (${result.activated.length})
+ ${result.activated.map((id) => {
+ const ch = payload.channels.find((c) => c.id === id)
+ return `- ${ch?.name ?? id} —
${id} `
+ }).join('')}
`
+ : ''
+
+ const notFoundHtml = result.notFound.length > 0
+ ? `Not Found in Playlists (${result.notFound.length})
+ ${result.notFound.map((id) => {
+ const ch = payload.channels.find((c) => c.id === id)
+ return `- ${ch?.name ?? id} —
${id} `
+ }).join('')}
+ These channels aren't in the current M3U playlists (english/news/sports). Add them to Threadfin's playlist sources first.
`
+ : ''
+
+ return htmlPage('Channels Approved', `
+ ✅
+ Channels Approved
+ Request from ${escapeHtml(payload.email)} has been processed.
+ ${activatedHtml}
+ ${notFoundHtml}
+ A confirmation email has been sent to the requester.
+ `)
+ } catch (err) {
+ console.error('Channel activation error:', err)
+ return htmlPage('Activation Failed', `
+ ❌
+ Activation Failed
+ Could not connect to Threadfin or activate channels.
+ ${escapeHtml(err instanceof Error ? err.message : String(err))}
+ Check Threadfin is running and credentials are correct.
+ `)
+ }
+}
+
+async function sendConfirmationEmail(
+ to: string,
+ activated: string[],
+ notFound: string[],
+ channels: { id: string; name: string }[],
+) {
+ const smtpHost = process.env.SMTP_HOST
+ const smtpUser = process.env.SMTP_USER
+ const smtpPass = process.env.SMTP_PASS
+ if (!smtpHost || !smtpUser || !smtpPass) return
+
+ const transporter = nodemailer.createTransport({
+ host: smtpHost,
+ port: Number(process.env.SMTP_PORT) || 587,
+ secure: false,
+ auth: { user: smtpUser, pass: smtpPass },
+ tls: { rejectUnauthorized: false },
+ })
+
+ const activatedList = activated.map((id) => {
+ const ch = channels.find((c) => c.id === id)
+ return `✅ ${escapeHtml(ch?.name ?? id)}`
+ }).join('')
+
+ const notFoundList = notFound.map((id) => {
+ const ch = channels.find((c) => c.id === id)
+ return `❌ ${escapeHtml(ch?.name ?? id)} — not available in current playlists`
+ }).join('')
+
+ await transporter.sendMail({
+ from: `Jefflix <${smtpUser}>`,
+ to,
+ subject: `[Jefflix] Your channel request has been processed`,
+ html: `
+ Your Channel Request Update
+ ${activated.length > 0 ? `
+ The following channels have been activated and should appear in your guide shortly:
+
+ ` : ''}
+ ${notFound.length > 0 ? `
+ The following channels were not found in the current playlists:
+
+ These may become available when new playlist sources are added.
+ ` : ''}
+ ${activated.length > 0 ? 'It may take a few minutes for the channels to appear in your TV guide. If you don\'t see them after 15 minutes, try refreshing your IPTV app.
' : ''}
+
+ Jefflix · ${new Date().toLocaleString()}
+ `,
+ })
+}
+
+function escapeHtml(text: string): string {
+ const map: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": ''',
+ }
+ return text.replace(/[&<>"']/g, (char) => map[char])
+}
diff --git a/app/api/request-channel/route.ts b/app/api/request-channel/route.ts
index 32c38e1..2a86974 100644
--- a/app/api/request-channel/route.ts
+++ b/app/api/request-channel/route.ts
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'
import nodemailer from 'nodemailer'
+import { createApproveToken } from '@/lib/token'
interface ChannelSelection {
id: string
@@ -75,6 +76,14 @@ export async function POST(request: NextRequest) {
? `[Jefflix] Channel Request: ${escapeHtml(channels[0].name)}`
: `[Jefflix] Channel Request: ${channels.length} channels`
+ // Generate approve token for one-click activation
+ const approveToken = createApproveToken(
+ channels.map((ch) => ({ id: ch.id, name: ch.name })),
+ email,
+ )
+ const baseUrl = process.env.NEXT_PUBLIC_BASE_URL || 'https://jefflix.lol'
+ const approveUrl = `${baseUrl}/api/approve-channels?token=${approveToken}`
+
await transporter.sendMail({
from: `Jefflix <${smtpUser}>`,
to: adminEmail,
@@ -82,15 +91,17 @@ export async function POST(request: NextRequest) {
html: `
New Channel Request
${escapeHtml(email)} requested ${channels.length} channel${channels.length > 1 ? 's' : ''}:
+
+
+ One click activates these channels in Threadfin and notifies the requester. Link expires in 7 days.
+
- To add these channels:
-
- - Search each channel ID in Threadfin / iptv-org
- - Map the streams in Threadfin
- - Reply to ${escapeHtml(email)} to confirm
-
Automated message from Jefflix · ${new Date().toLocaleString()}
`,
diff --git a/docker-compose.yml b/docker-compose.yml
index 299a1b3..cd17ec4 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,10 @@ services:
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- ADMIN_EMAIL=${ADMIN_EMAIL:-jeff@jeffemmett.com}
+ - TOKEN_SECRET=${TOKEN_SECRET}
+ - THREADFIN_URL=https://threadfin.jefflix.lol
+ - THREADFIN_USER=${THREADFIN_USER}
+ - THREADFIN_PASS=${THREADFIN_PASS}
labels:
- "traefik.enable=true"
- "traefik.http.routers.jefflix-website.rule=Host(`jefflix.lol`) || Host(`www.jefflix.lol`)"
diff --git a/lib/threadfin.ts b/lib/threadfin.ts
new file mode 100644
index 0000000..7236256
--- /dev/null
+++ b/lib/threadfin.ts
@@ -0,0 +1,128 @@
+import WebSocket from 'ws'
+
+interface ThreadfinConfig {
+ url: string
+ user: string
+ pass: string
+}
+
+interface XepgEntry {
+ 'tvg-id': string
+ 'x-active': boolean
+ [key: string]: unknown
+}
+
+type EpgMapping = Record
+
+interface ActivateResult {
+ activated: string[]
+ notFound: string[]
+}
+
+function getConfig(): ThreadfinConfig {
+ const url = process.env.THREADFIN_URL
+ const user = process.env.THREADFIN_USER
+ const pass = process.env.THREADFIN_PASS
+ if (!url || !user || !pass) {
+ throw new Error('THREADFIN_URL, THREADFIN_USER, THREADFIN_PASS must be set')
+ }
+ return { url: url.replace(/\/$/, ''), user, pass }
+}
+
+async function login(): Promise {
+ const { url, user, pass } = getConfig()
+ const res = await fetch(`${url}/api/`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ cmd: 'login', username: user, password: pass }),
+ })
+ if (!res.ok) throw new Error(`Threadfin login failed: ${res.status}`)
+ const data = await res.json()
+ if (!data.token) throw new Error('Threadfin login returned no token')
+ return data.token
+}
+
+export async function activateChannels(
+ channelIds: string[],
+): Promise {
+ const { url } = getConfig()
+ const token = await login()
+
+ const wsUrl = url.replace(/^http/, 'ws') + `/data/?Token=${token}`
+
+ return new Promise((resolve, reject) => {
+ const ws = new WebSocket(wsUrl)
+ const timeout = setTimeout(() => {
+ ws.close()
+ reject(new Error('Threadfin WebSocket timed out after 30s'))
+ }, 30_000)
+
+ let currentToken = token
+
+ ws.on('error', (err) => {
+ clearTimeout(timeout)
+ reject(err)
+ })
+
+ ws.on('message', (raw) => {
+ try {
+ const msg = JSON.parse(raw.toString())
+
+ // Update token if rotated
+ if (msg.token) currentToken = msg.token
+
+ // Initial data response contains the xepg map
+ if (msg.xepg) {
+ const epgMapping: EpgMapping = msg.xepg
+
+ const activated: string[] = []
+ const idSet = new Set(channelIds)
+ const foundIds = new Set()
+
+ // Find and activate matching channels
+ for (const [key, entry] of Object.entries(epgMapping)) {
+ const tvgId = entry['tvg-id']
+ if (tvgId && idSet.has(tvgId)) {
+ entry['x-active'] = true
+ activated.push(tvgId)
+ foundIds.add(tvgId)
+ }
+ }
+
+ const notFound = channelIds.filter((id) => !foundIds.has(id))
+
+ if (activated.length === 0) {
+ clearTimeout(timeout)
+ ws.close()
+ resolve({ activated, notFound })
+ return
+ }
+
+ // Must send the ENTIRE map back
+ const saveMsg = JSON.stringify({
+ cmd: 'saveEpgMapping',
+ epgMapping,
+ token: currentToken,
+ })
+ ws.send(saveMsg, (err) => {
+ clearTimeout(timeout)
+ if (err) {
+ ws.close()
+ reject(err)
+ return
+ }
+ // Give Threadfin a moment to process, then close
+ setTimeout(() => {
+ ws.close()
+ resolve({ activated, notFound })
+ }, 1000)
+ })
+ }
+ } catch (err) {
+ clearTimeout(timeout)
+ ws.close()
+ reject(err)
+ }
+ })
+ })
+}
diff --git a/lib/token.ts b/lib/token.ts
new file mode 100644
index 0000000..4e58da6
--- /dev/null
+++ b/lib/token.ts
@@ -0,0 +1,58 @@
+import { createHmac } from 'crypto'
+
+interface ApprovePayload {
+ channels: { id: string; name: string }[]
+ email: string
+ exp: number
+}
+
+const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
+
+function getSecret(): string {
+ const secret = process.env.TOKEN_SECRET
+ if (!secret) throw new Error('TOKEN_SECRET env var is not set')
+ return secret
+}
+
+export function createApproveToken(
+ channels: { id: string; name: string }[],
+ email: string,
+): string {
+ const payload: ApprovePayload = {
+ channels,
+ email,
+ exp: Date.now() + SEVEN_DAYS_MS,
+ }
+ const data = Buffer.from(JSON.stringify(payload)).toString('base64url')
+ const sig = createHmac('sha256', getSecret()).update(data).digest('hex')
+ return `${data}.${sig}`
+}
+
+export function verifyApproveToken(token: string): ApprovePayload | null {
+ const parts = token.split('.')
+ if (parts.length !== 2) return null
+
+ const [data, sig] = parts
+ const expected = createHmac('sha256', getSecret()).update(data).digest('hex')
+
+ // Constant-time comparison
+ if (sig.length !== expected.length) return null
+ let mismatch = 0
+ for (let i = 0; i < sig.length; i++) {
+ mismatch |= sig.charCodeAt(i) ^ expected.charCodeAt(i)
+ }
+ if (mismatch !== 0) return null
+
+ try {
+ const payload: ApprovePayload = JSON.parse(
+ Buffer.from(data, 'base64url').toString('utf-8'),
+ )
+
+ if (!payload.exp || !payload.channels || !payload.email) return null
+ if (Date.now() > payload.exp) return null
+
+ return payload
+ } catch {
+ return null
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 2c80526..22b986a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,7 +36,6 @@
"@radix-ui/react-toggle": "1.1.1",
"@radix-ui/react-toggle-group": "1.1.1",
"@radix-ui/react-tooltip": "1.1.6",
- "@vercel/analytics": "latest",
"autoprefixer": "^10.4.20",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -58,6 +57,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
+ "ws": "^8.20.0",
"zod": "3.25.76"
},
"devDependencies": {
@@ -66,6 +66,7 @@
"@types/nodemailer": "^7.0.9",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/ws": "^8.18.1",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",
@@ -2506,42 +2507,14 @@
"@types/react": "^19.2.0"
}
},
- "node_modules/@vercel/analytics": {
- "version": "1.6.1",
- "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.6.1.tgz",
- "integrity": "sha512-oH9He/bEM+6oKlv3chWuOOcp8Y6fo6/PSro8hEkgCW3pu9/OiCXiUpRUogDh3Fs3LH2sosDrx8CxeOLBEE+afg==",
- "license": "MPL-2.0",
- "peerDependencies": {
- "@remix-run/react": "^2",
- "@sveltejs/kit": "^1 || ^2",
- "next": ">= 13",
- "react": "^18 || ^19 || ^19.0.0-rc",
- "svelte": ">= 4",
- "vue": "^3",
- "vue-router": "^4"
- },
- "peerDependenciesMeta": {
- "@remix-run/react": {
- "optional": true
- },
- "@sveltejs/kit": {
- "optional": true
- },
- "next": {
- "optional": true
- },
- "react": {
- "optional": true
- },
- "svelte": {
- "optional": true
- },
- "vue": {
- "optional": true
- },
- "vue-router": {
- "optional": true
- }
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
}
},
"node_modules/aria-hidden": {
@@ -4006,6 +3979,27 @@
"d3-timer": "^3.0.1"
}
},
+ "node_modules/ws": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+ "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
diff --git a/package.json b/package.json
index 2347872..c4701a6 100644
--- a/package.json
+++ b/package.json
@@ -58,6 +58,7 @@
"tailwind-merge": "^2.5.5",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
+ "ws": "^8.20.0",
"zod": "3.25.76"
},
"devDependencies": {
@@ -66,6 +67,7 @@
"@types/nodemailer": "^7.0.9",
"@types/react": "^19",
"@types/react-dom": "^19",
+ "@types/ws": "^8.18.1",
"postcss": "^8.5",
"tailwindcss": "^4.1.9",
"tw-animate-css": "1.3.3",