diff --git a/README.md b/README.md index 48189c1..1212158 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/distribution/README.md b/distribution/README.md new file mode 100644 index 0000000..c0e05c9 --- /dev/null +++ b/distribution/README.md @@ -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`. diff --git a/distribution/common/config.env.example b/distribution/common/config.env.example new file mode 100644 index 0000000..d77b2b6 --- /dev/null +++ b/distribution/common/config.env.example @@ -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= diff --git a/distribution/common/start-connector.sh b/distribution/common/start-connector.sh new file mode 100644 index 0000000..82c58bd --- /dev/null +++ b/distribution/common/start-connector.sh @@ -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" diff --git a/distribution/linux/cleanflix-connector.service b/distribution/linux/cleanflix-connector.service new file mode 100644 index 0000000..a807a01 --- /dev/null +++ b/distribution/linux/cleanflix-connector.service @@ -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 diff --git a/distribution/linux/install.sh b/distribution/linux/install.sh new file mode 100644 index 0000000..f7673cf --- /dev/null +++ b/distribution/linux/install.sh @@ -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" diff --git a/distribution/macos/launchd/com.cleanflix.connector.plist b/distribution/macos/launchd/com.cleanflix.connector.plist new file mode 100644 index 0000000..7dc660c --- /dev/null +++ b/distribution/macos/launchd/com.cleanflix.connector.plist @@ -0,0 +1,3 @@ + + +Labelcom.cleanflix.connectorProgramArguments/bin/sh-lcexport 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"RunAtLoadKeepAlive diff --git a/distribution/windows/install.ps1 b/distribution/windows/install.ps1 new file mode 100644 index 0000000..418334b --- /dev/null +++ b/distribution/windows/install.ps1 @@ -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." diff --git a/library.js b/library.js new file mode 100644 index 0000000..c55f9b5 --- /dev/null +++ b/library.js @@ -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}[^>]*>([^<]+)`, '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(/]*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; + }, + }; +} diff --git a/package.json b/package.json index e005615..4a0f783 100644 --- a/package.json +++ b/package.json @@ -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" + } } diff --git a/scripts/build-desktop-package.sh b/scripts/build-desktop-package.sh new file mode 100644 index 0000000..0fca335 --- /dev/null +++ b/scripts/build-desktop-package.sh @@ -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" diff --git a/server.js b/server.js index 4ce0494..09e1529 100644 --- a/server.js +++ b/server.js @@ -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('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); +const libraryPage = items => `CleanStream Library

Library review

Confirm titles and IMDb IDs. Original files are never renamed or moved.

${items.map(item => `
${html(item.file)}${item.candidate ? ` ยท TMDB: ${html(item.candidate.title)} (${html(item.candidate.year)})` : ''}
`).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('
'); } 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}`));