feat: add desktop connector distributions

This commit is contained in:
2026-08-07 17:01:30 +01:00
parent f8b1724add
commit 84db4b766c
12 changed files with 356 additions and 14 deletions
+20
View File
@@ -29,3 +29,23 @@ The connector is available at `https://cleanstream-connector.test`. The TV and
phone must be on the same LAN. Because this uses Caddy's internal certificate,
the phone must trust the local Caddy root certificate before it can open the QR
approval URL.
## Library metadata
Open `/library` in a browser and authenticate with any username and the pairing password. The
scanner uses `guessit-js` to suggest a title, year, and episode information from
messy release names; confirm or correct the title and IMDb ID in the review UI.
Mappings persist in `data/library-index.json`, are keyed by relative media path,
and are used for the catalog and CleanStream skip lookup. The connector scans on
every catalog or library request, so added and removed files are reflected
without moving or renaming originals.
The scanner also reads a same-name `.nfo` sidecar or `movie.nfo` in the media
folder. NFO title, year, and IMDb IDs take precedence over filename suggestions;
explicit library-manager mappings take precedence over both.
When `TMDB_API_KEY` is set, it is used only by the connector to enrich catalog
and detail responses with public artwork and title metadata (overview, runtime,
genres, year, and rating). The key is never included in a response, image URL,
or client configuration. Without a key or when TMDB cannot match a title, the
connector continues to return the locally derived title and file description.
+21
View File
@@ -0,0 +1,21 @@
# Cleanflix Connector Desktop Packages
Each desktop package contains the connector source plus a bundled Node 22 runtime.
Set `MEDIA_ROOT`, `PAIRING_PASSWORD_HASH`, and `PUBLIC_BASE_URL` in the generated
configuration before starting it. `PUBLIC_BASE_URL` must be an HTTPS address your TV
and phone can reach on the same LAN.
## macOS
Copy `macos/Cleanflix Connector.app` to Applications, open it once, then edit:
`~/Library/Application Support/Cleanflix Connector/config.env`.
## Windows
Run `windows/install.ps1` in PowerShell. It installs a per-user Scheduled Task and
opens `%APPDATA%\Cleanflix Connector\config.env`.
## Linux
Run `linux/install.sh`. It installs a user systemd service and creates:
`~/.config/cleanflix-connector/config.env`.
+6
View File
@@ -0,0 +1,6 @@
PORT=8787
MEDIA_ROOT=/absolute/path/to/your/media
PUBLIC_BASE_URL=https://connector.example.home
APPROVAL_BASE_URL=https://connector.example.home
# Generate with: node hash-password.js "a-long-private-password"
PAIRING_PASSWORD_HASH=
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env sh
set -eu
CONFIG_PATH=${CLEANFLIX_CONFIG_PATH:?CLEANFLIX_CONFIG_PATH is required}
APP_ROOT=${CLEANFLIX_APP_ROOT:?CLEANFLIX_APP_ROOT is required}
[ -f "$CONFIG_PATH" ] || { printf '%s\n' "Missing configuration: $CONFIG_PATH" >&2; exit 1; }
set -a
. "$CONFIG_PATH"
set +a
: "${MEDIA_ROOT:?Set MEDIA_ROOT in config.env}"
: "${PAIRING_PASSWORD_HASH:?Set PAIRING_PASSWORD_HASH in config.env}"
: "${PUBLIC_BASE_URL:?Set PUBLIC_BASE_URL in config.env}"
export TOKEN_STORE_PATH="${TOKEN_STORE_PATH:-$(dirname "$CONFIG_PATH")/data/tokens.json}"
export LIBRARY_INDEX_PATH="${LIBRARY_INDEX_PATH:-$(dirname "$CONFIG_PATH")/data/library-index.json}"
exec "$CLEANFLIX_NODE" "$APP_ROOT/server.js"
@@ -0,0 +1,14 @@
[Unit]
Description=Cleanflix personal media connector
After=network-online.target
[Service]
Type=simple
Environment=CLEANFLIX_APP_ROOT=%h/.local/lib/cleanflix-connector
Environment=CLEANFLIX_CONFIG_PATH=%h/.config/cleanflix-connector/config.env
Environment=CLEANFLIX_NODE=%h/.local/lib/cleanflix-connector/node/bin/node
ExecStart=%h/.local/lib/cleanflix-connector/distribution/common/start-connector.sh
Restart=on-failure
[Install]
WantedBy=default.target
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env sh
set -eu
ROOT="${CLEANFLIX_INSTALL_ROOT:-$HOME/.local/lib/cleanflix-connector}"
CONFIG="$HOME/.config/cleanflix-connector"
mkdir -p "$CONFIG" "$HOME/.config/systemd/user"
cp distribution/common/config.env.example "$CONFIG/config.env"
sed "s#%h#$HOME#g" distribution/linux/cleanflix-connector.service > "$HOME/.config/systemd/user/cleanflix-connector.service"
systemctl --user daemon-reload
systemctl --user enable --now cleanflix-connector.service
printf '%s\n' "Edit $CONFIG/config.env, then run: systemctl --user restart cleanflix-connector"
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>Label</key><string>com.cleanflix.connector</string><key>ProgramArguments</key><array><string>/bin/sh</string><string>-lc</string><string>export CLEANFLIX_APP_ROOT="$HOME/Library/Application Support/Cleanflix Connector" CLEANFLIX_CONFIG_PATH="$HOME/Library/Application Support/Cleanflix Connector/config.env" CLEANFLIX_NODE="$HOME/Library/Application Support/Cleanflix Connector/node/bin/node"; exec "$CLEANFLIX_APP_ROOT/distribution/common/start-connector.sh"</string></array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/></dict></plist>
+9
View File
@@ -0,0 +1,9 @@
$Root = Join-Path $env:LOCALAPPDATA 'Cleanflix Connector'
$Config = Join-Path $env:APPDATA 'Cleanflix Connector'
New-Item -ItemType Directory -Force -Path $Config | Out-Null
Copy-Item "$PSScriptRoot\..\common\config.env.example" "$Config\config.env" -Force
$Action = New-ScheduledTaskAction -Execute "$Root\node\node.exe" -Argument "`"$Root\server.js`""
$Trigger = New-ScheduledTaskTrigger -AtLogOn
Register-ScheduledTask -TaskName 'Cleanflix Connector' -Action $Action -Trigger $Trigger -Force | Out-Null
Start-Process notepad.exe "$Config\config.env"
Write-Host "Edit config.env, then start the Cleanflix Connector scheduled task."
+164
View File
@@ -0,0 +1,164 @@
import fs from 'node:fs';
import path from 'node:path';
import { guessit } from 'guessit-js';
const video = /\.(mkv|mp4|webm|m4v)$/i;
const text = (source, tag) => source.match(new RegExp(`<${tag}[^>]*>([^<]+)</${tag}>`, 'i'))?.[1]?.trim();
const nfoFor = (mediaRoot, file) => {
const directory = path.dirname(path.join(mediaRoot, file));
const stem = path.basename(file, path.extname(file));
const nfoPath = [path.join(directory, `${stem}.nfo`), path.join(directory, 'movie.nfo')].find(fs.existsSync);
if (!nfoPath) return {};
try {
const source = fs.readFileSync(nfoPath, 'utf8');
return {
title: text(source, 'title'),
year: Number(text(source, 'year')) || null,
imdbId: source.match(/<uniqueid[^>]*type=["']imdb["'][^>]*>(tt\d{7,10})<\/uniqueid>/i)?.[1] || text(source, 'id')?.match(/^tt\d{7,10}$/i)?.[0]?.toLowerCase(),
};
} catch { return {}; }
};
export function createLibrary(mediaRoot, indexPath, tmdbApiKey) {
const metadataCache = new Map();
const load = () => {
try { return JSON.parse(fs.readFileSync(indexPath, 'utf8')); } catch { return { mappings: {} }; }
};
const save = index => {
fs.mkdirSync(path.dirname(indexPath), { recursive: true });
fs.writeFileSync(indexPath, `${JSON.stringify(index, null, 2)}\n`);
};
const discover = async item => {
if (item.imdbId || !tmdbApiKey || !item.title) return item;
const type = item.type === 'episode' ? 'tv' : 'movie';
const query = new URLSearchParams({ api_key: tmdbApiKey, query: item.title });
if (item.year) query.set(type === 'movie' ? 'year' : 'first_air_date_year', item.year);
const results = await fetch(`https://api.themoviedb.org/3/search/${type}?${query}`).then(res => res.ok ? res.json() : { results: [] });
const candidate = results.results?.[0];
if (!candidate) return item;
const candidateTitle = candidate.title || candidate.name || '';
const candidateYear = (candidate.release_date || candidate.first_air_date || '').slice(0, 4);
const normalize = value => value.toLowerCase().replace(/[^a-z0-9]/g, '');
const confident = Boolean(item.year) && normalize(candidateTitle) === normalize(item.title) && candidateYear === String(item.year);
if (!confident) return { ...item, candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } };
const external = await fetch(`https://api.themoviedb.org/3/${type}/${candidate.id}/external_ids?api_key=${tmdbApiKey}`).then(res => res.ok ? res.json() : {});
return { ...item, suggestedImdbId: external.imdb_id || null, autoMapped: Boolean(external.imdb_id), candidate: { title: candidateTitle, year: candidateYear, tmdbId: candidate.id } };
};
const tmdb = async (endpoint, query = {}) => {
if (!tmdbApiKey) return null;
const params = new URLSearchParams({ api_key: tmdbApiKey, ...query });
const response = await fetch(`https://api.themoviedb.org/3${endpoint}?${params}`);
return response.ok ? response.json() : null;
};
const imageUrl = (image, size) => image ? `https://image.tmdb.org/t/p/${size}${image}` : null;
const metadataFor = async item => {
const key = `${item.imdbId || ''}:${item.title}:${item.year || ''}:${item.type}`;
if (metadataCache.has(key)) return metadataCache.get(key);
const pending = (async () => {
if (!tmdbApiKey) return item;
const type = item.type === 'episode' ? 'tv' : 'movie';
let result;
if (item.imdbId) {
const external = await tmdb(`/find/${item.imdbId}`, { external_source: 'imdb_id' });
result = external?.[type === 'tv' ? 'tv_results' : 'movie_results']?.[0];
}
if (!result) {
const query = { query: item.title };
if (item.year) query[type === 'tv' ? 'first_air_date_year' : 'year'] = item.year;
result = (await tmdb(`/search/${type}`, query))?.results?.[0];
}
if (!result?.id) return item;
const detail = await tmdb(`/${type}/${result.id}`, { append_to_response: 'external_ids' });
if (!detail) return item;
const releaseDate = detail.release_date || detail.first_air_date || '';
return {
...item,
imdbId: item.imdbId || detail.external_ids?.imdb_id || null,
posterUrl: imageUrl(detail.poster_path, 'w500'),
backgroundUrl: imageUrl(detail.backdrop_path, 'w1280'),
overview: detail.overview || null,
description: detail.overview || item.description || null,
runtime: detail.runtime || detail.episode_run_time?.[0] || null,
genres: detail.genres?.map(genre => genre.name).filter(Boolean) || [],
releaseYear: Number(releaseDate.slice(0, 4)) || item.year || null,
rating: typeof detail.vote_average === 'number' ? detail.vote_average : null,
};
})().catch(() => item);
metadataCache.set(key, pending);
return pending;
};
const scan = async () => {
const index = load();
const files = fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true }).filter(file => video.test(file)) : [];
const present = new Set(files);
let changed = false;
for (const file of Object.keys(index.mappings)) {
if (!present.has(file) && !index.mappings[file].missing) { index.mappings[file].missing = true; changed = true; }
}
const items = files.map(file => {
const stat = fs.statSync(path.join(mediaRoot, file));
const fingerprint = `${stat.size}:${Math.round(stat.mtimeMs)}`;
const parsed = guessit(path.basename(file));
const previous = Object.entries(index.mappings).find(([oldPath, value]) => oldPath !== file && value.missing && value.fingerprint === fingerprint)?.[1];
const mapping = index.mappings[file] || previous || {};
const nfo = nfoFor(mediaRoot, file);
if (mapping.fingerprint !== fingerprint || mapping.missing) {
index.mappings[file] = { ...mapping, fingerprint, missing: false, updatedAt: mapping.updatedAt || new Date().toISOString() };
changed = true;
}
return {
file,
id: Buffer.from(file).toString('base64url'),
title: mapping.title || nfo.title || parsed.title || path.basename(file, path.extname(file)),
year: mapping.year || nfo.year || parsed.year || null,
imdbId: mapping.imdbId || nfo.imdbId || parsed.imdb_id || file.match(/\btt\d{7,10}\b/i)?.[0]?.toLowerCase() || null,
type: parsed.type === 'episode' ? 'episode' : 'video',
season: mapping.season || parsed.season || null,
episode: mapping.episode || parsed.episode || null,
mapped: Boolean(mapping.imdbId),
};
});
const discovered = await Promise.all(items.map(discover));
for (const item of discovered) {
if (!item.autoMapped || index.mappings[item.file]?.imdbId) continue;
index.mappings[item.file] = {
title: item.title,
year: item.year,
imdbId: item.suggestedImdbId,
source: 'tmdb-auto',
confidence: 'high',
missing: false,
fingerprint: index.mappings[item.file]?.fingerprint,
updatedAt: new Date().toISOString(),
};
changed = true;
}
if (changed) save(index);
return discovered;
};
return {
scan,
startReconciler(seconds = 900) {
const refresh = () => scan().catch(error => console.error('Library reconciliation failed', error));
refresh();
setInterval(refresh, seconds * 1000).unref();
try {
let pending;
fs.watch(mediaRoot, () => { clearTimeout(pending); pending = setTimeout(refresh, 1_000); });
} catch (error) {
console.warn('Library watcher unavailable; periodic reconciliation remains active', error.message);
}
},
saveMapping(file, mapping) {
const index = load();
index.mappings[file] = { ...index.mappings[file], ...mapping, missing: false, updatedAt: new Date().toISOString() };
save(index);
},
async catalog() {
return Promise.all((await scan()).map(metadataFor));
},
async metadata(id) {
return (await this.catalog()).find(item => item.id === id) || null;
},
};
}
+9 -1
View File
@@ -2,5 +2,13 @@
"name": "cleanstream-media-connector-reference",
"private": true,
"type": "module",
"scripts": { "start": "node server.js" }
"scripts": {
"start": "node server.js",
"package:macos": "sh scripts/build-desktop-package.sh macos",
"package:linux": "sh scripts/build-desktop-package.sh linux",
"package:windows": "sh scripts/build-desktop-package.sh windows"
},
"dependencies": {
"guessit-js": "^4.0.0"
}
}
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env sh
set -eu
PLATFORM=${1:?usage: build-desktop-package.sh macos|linux|windows}
VERSION=${VERSION:-0.1.0}
NODE_VERSION=${NODE_VERSION:-22.14.0}
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
DIST="$ROOT/release/cleanflix-connector-$PLATFORM-$VERSION"
case "$PLATFORM" in
macos) ARCHIVE="node-v$NODE_VERSION-darwin-arm64" ;;
linux) ARCHIVE="node-v$NODE_VERSION-linux-x64" ;;
windows) ARCHIVE="node-v$NODE_VERSION-win-x64" ;;
*) exit 1 ;;
esac
rm -rf "$DIST"
mkdir -p "$DIST"
cp "$ROOT/server.js" "$ROOT/library.js" "$ROOT/hash-password.js" "$ROOT/package.json" "$DIST/"
cp -R "$ROOT/distribution" "$DIST/"
mkdir -p "$DIST/data" "$DIST/media"
if [ "$PLATFORM" = windows ]; then
curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/$ARCHIVE.zip"
unzip -q "$ARCHIVE.zip"
rm -f "$ARCHIVE.zip"
else
curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/$ARCHIVE.tar.gz"
tar -xzf "$ARCHIVE.tar.gz"
rm -f "$ARCHIVE.tar.gz"
fi
mv "$ARCHIVE" "$DIST/node"
printf '%s\n' "Built $DIST"
+50 -13
View File
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
import fs from 'node:fs';
import http from 'node:http';
import path from 'node:path';
import { createLibrary } from './library.js';
const port = Number(process.env.PORT || 8787);
const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media');
@@ -10,20 +11,35 @@ const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace
const pairingSecret = process.env.PAIRING_SECRET;
const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH;
const demoStreamUrl = process.env.DEMO_STREAM_URL;
const library = createLibrary(mediaRoot, path.resolve(process.env.LIBRARY_INDEX_PATH || './data/library-index.json'), process.env.TMDB_API_KEY);
const tokenPath = path.resolve(process.env.TOKEN_STORE_PATH || './data/tokens.json');
library.startReconciler(Number(process.env.LIBRARY_RECONCILE_SECONDS || 900));
if (!pairingSecret && !pairingPasswordHash) throw new Error('PAIRING_PASSWORD_HASH or PAIRING_SECRET is required');
const sessions = new Map();
const tokens = new Map();
const videos = () => [
...(fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true })
.filter(file => /\.(mkv|mp4|webm|m4v)$/i.test(file))
.map(file => ({ id: Buffer.from(file).toString('base64url'), title: path.basename(file, path.extname(file)), type: 'video', description: file })) : []),
const loadTokens = () => {
try { return new Map(Object.entries(JSON.parse(fs.readFileSync(tokenPath, 'utf8')))); } catch { return new Map(); }
};
const tokens = loadTokens();
const saveTokens = () => {
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
fs.writeFileSync(tokenPath, `${JSON.stringify(Object.fromEntries(tokens))}\n`, { mode: 0o600 });
};
const videos = async () => [
...(await library.catalog()).map(item => ({ ...item, imdbId: item.imdbId || item.suggestedImdbId, description: item.description || item.file })),
...(demoStreamUrl ? [{ id: 'demo-stream', title: 'Connector playback test', type: 'video', description: 'Staging test stream' }] : []),
];
const json = (res, code, value) => { const body = JSON.stringify(value); console.log(`${code} ${body}`); res.writeHead(code, { 'content-type': 'application/json' }); res.end(body); };
// Responses can contain pairing and stream tokens; never write their bodies to logs.
const json = (res, code, value) => { const body = JSON.stringify(value); console.log(code); res.writeHead(code, { 'content-type': 'application/json' }); res.end(body); };
const readJson = req => new Promise((resolve, reject) => { let raw = ''; req.on('data', d => raw += d); req.on('end', () => { try { resolve(JSON.parse(raw || '{}')); } catch { reject(new Error('Invalid JSON')); } }); });
const bearer = req => req.headers.authorization?.replace(/^Bearer\s+/i, '');
const authenticated = req => tokens.has(bearer(req));
const authenticated = req => {
const token = bearer(req);
const expiresAt = Number(tokens.get(token));
if (expiresAt > Date.now()) return true;
if (token) { tokens.delete(token); saveTokens(); }
return false;
};
const id = () => crypto.randomBytes(24).toString('base64url');
const passwordMatches = value => {
if (pairingPasswordHash) {
@@ -32,15 +48,31 @@ const passwordMatches = value => {
const actual = crypto.scryptSync(value, salt, 32).toString('base64url');
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
}
return Boolean(pairingSecret) && crypto.timingSafeEqual(Buffer.from(value), Buffer.from(pairingSecret));
const actual = Buffer.from(value);
const expected = Buffer.from(pairingSecret || '');
return Boolean(pairingSecret) && actual.length === expected.length && crypto.timingSafeEqual(actual, expected);
};
const admin = req => {
const raw = req.headers.authorization?.replace(/^Basic\s+/i, '');
return Boolean(raw) && passwordMatches(Buffer.from(raw, 'base64').toString().split(':').slice(1).join(':'));
};
const html = value => String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;');
const libraryPage = items => `<!doctype html><title>CleanStream Library</title><style>body{font:16px system-ui;max-width:1100px;margin:2rem auto}form{display:grid;grid-template-columns:2fr 2fr 1fr auto;gap:.5rem;margin:.5rem 0}input{padding:.5rem}small{grid-column:1/-1;color:#666}</style><h1>Library review</h1><p>Confirm titles and IMDb IDs. Original files are never renamed or moved.</p>${items.map(item => `<form method="post" action="/library/mapping"><input type="hidden" name="file" value="${encodeURIComponent(item.file)}"><input name="title" value="${html(item.title)}"><input name="imdbId" placeholder="tt1234567" value="${html(item.imdbId || item.suggestedImdbId || '')}"><input name="year" value="${item.year || ''}" placeholder="year"><button>Save</button><small>${html(item.file)}${item.candidate ? ` · TMDB: ${html(item.candidate.title)} (${html(item.candidate.year)})` : ''}</small></form>`).join('')}`;
http.createServer(async (req, res) => {
const url = new URL(req.url, publicBaseUrl);
console.log(`${req.method} ${url.pathname}`);
if (req.method === 'GET' && url.pathname === '/library') {
if (!admin(req)) { res.writeHead(401, { 'www-authenticate': 'Basic realm="CleanStream Library"' }); return res.end('Authentication required'); }
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(libraryPage(await library.scan()));
}
if (req.method === 'POST' && url.pathname === '/library/mapping') {
if (!admin(req)) return json(res, 401, { error: 'Unauthorized' });
let raw = ''; req.on('data', d => raw += d); req.on('end', () => { const body = new URLSearchParams(raw); const file = decodeURIComponent(body.get('file') || ''); if (!file || file.includes('..')) return json(res, 400, { error: 'Invalid file' }); library.saveMapping(file, { title: body.get('title') || undefined, imdbId: body.get('imdbId') || undefined, year: Number(body.get('year')) || undefined }); res.writeHead(303, { location: '/library' }); res.end(); }); return;
}
if (req.method === 'GET' && url.pathname === '/.well-known/cleanstream-connector.json') return json(res, 200, {
protocolVersion: 1, id: 'reference.home-library', name: 'Reference Home Library', capabilities: ['catalog', 'stream'],
endpoints: { catalog: '/v1/catalog', stream: '/v1/stream', pairStart: '/v1/pair/start', pairStatus: '/v1/pair/{sessionId}', pairToken: '/v1/pair/token' }
protocolVersion: 1, id: 'reference.home-library', name: 'Reference Home Library', capabilities: ['catalog', 'metadata', 'stream'],
endpoints: { catalog: '/v1/catalog', metadata: '/v1/metadata', stream: '/v1/stream', pairStart: '/v1/pair/start', pairStatus: '/v1/pair/{sessionId}', pairToken: '/v1/pair/token' }
});
if (req.method === 'POST' && url.pathname === '/v1/pair/start') {
const body = await readJson(req); if ((body.codeChallengeMethod || 'S256') !== 'S256' || !body.codeChallenge) return json(res, 400, { error: 'PKCE S256 required' });
@@ -50,9 +82,14 @@ http.createServer(async (req, res) => {
if (req.method === 'GET' && url.pathname === '/pair/approve') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end('<form method="post"><input name="secret" type="password" placeholder="Pairing secret"><button>Approve TV</button></form>'); }
if (req.method === 'POST' && url.pathname === '/pair/approve') { let raw=''; req.on('data', d => raw += d); req.on('end', () => { const session=sessions.get(url.searchParams.get('session')); if (!session || !passwordMatches(new URLSearchParams(raw).get('secret') || '')) { res.statusCode=403; return res.end('Denied'); } session.code=id(); res.end('Approved. Return to your TV.'); }); return; }
if (req.method === 'GET' && url.pathname.startsWith('/v1/pair/')) { const session=sessions.get(url.pathname.split('/').pop()); if (!session || session.expires < Date.now()) return json(res, 200, { status: 'expired' }); return json(res, 200, session.code ? { status: 'approved', authorizationCode: session.code } : { status: 'pending' }); }
if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); }
if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: videos() }) : json(res, 401, { error: 'Unauthorized' });
if (req.method === 'POST' && url.pathname === '/v1/stream') { if (!authenticated(req)) return json(res,401,{error:'Unauthorized'}); const body=await readJson(req); if (body.id === 'demo-stream' && demoStreamUrl) return json(res,200,{streams:[{id:'demo-stream',url:demoStreamUrl,filename:'demo.m3u8',headers:{}}]}); const file=Buffer.from(body.id || '', 'base64url').toString(); if (!videos().some(v => v.id === body.id) || file.includes('..')) return json(res,404,{error:'Not found'}); return json(res,200,{streams:[{id:body.id,url:`${publicBaseUrl}/v1/media/${body.id}?token=${bearer(req)}`,filename:path.basename(file),headers:{}}]}); }
if (req.method === 'POST' && url.pathname === '/v1/pair/token') { const body=await readJson(req), session=sessions.get(body.sessionId); const challenge=crypto.createHash('sha256').update(body.codeVerifier || '').digest('base64url'); if (!session || session.code !== body.authorizationCode || session.challenge !== challenge) return json(res, 403, { error: 'Invalid pairing exchange' }); const token=id(); tokens.set(token, Date.now()+2592000000); saveTokens(); sessions.delete(body.sessionId); return json(res, 200, { accessToken: token, expiresAt: new Date(tokens.get(token)).toISOString(), scopes: ['catalog','stream'] }); }
if (req.method === 'GET' && url.pathname === '/v1/catalog') return authenticated(req) ? json(res, 200, { items: await videos() }) : json(res, 401, { error: 'Unauthorized' });
if (req.method === 'GET' && url.pathname === '/v1/metadata') {
if (!authenticated(req)) return json(res, 401, { error: 'Unauthorized' });
const item = await library.metadata(url.searchParams.get('id') || '');
return item ? json(res, 200, { item: { ...item, imdbId: item.imdbId || item.suggestedImdbId, description: item.description || item.file } }) : json(res, 404, { error: 'Not found' });
}
if (req.method === 'POST' && url.pathname === '/v1/stream') { if (!authenticated(req)) return json(res,401,{error:'Unauthorized'}); const body=await readJson(req); if (body.id === 'demo-stream' && demoStreamUrl) return json(res,200,{streams:[{id:'demo-stream',url:demoStreamUrl,filename:'demo.m3u8',headers:{}}]}); const file=Buffer.from(body.id || '', 'base64url').toString(); if (!(await videos()).some(v => v.id === body.id) || file.includes('..')) return json(res,404,{error:'Not found'}); return json(res,200,{streams:[{id:body.id,url:`${publicBaseUrl}/v1/media/${body.id}?token=${bearer(req)}`,filename:path.basename(file),headers:{}}]}); }
if (req.method === 'GET' && url.pathname.startsWith('/v1/media/')) { const token=url.searchParams.get('token'); const file=Buffer.from(url.pathname.split('/').pop(), 'base64url').toString(); const target=path.resolve(mediaRoot,file); if (!tokens.has(token) || !target.startsWith(`${mediaRoot}${path.sep}`) || !fs.existsSync(target)) return json(res,404,{error:'Not found'}); res.writeHead(200,{'content-type':'application/octet-stream','content-length':fs.statSync(target).size}); return fs.createReadStream(target).pipe(res); }
json(res, 404, { error: 'Not found' });
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));