feat: add desktop connector distributions
This commit is contained in:
@@ -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,
|
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
|
the phone must trust the local Caddy root certificate before it can open the QR
|
||||||
approval URL.
|
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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
@@ -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
@@ -2,5 +2,13 @@
|
|||||||
"name": "cleanstream-media-connector-reference",
|
"name": "cleanstream-media-connector-reference",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
import { createLibrary } from './library.js';
|
||||||
|
|
||||||
const port = Number(process.env.PORT || 8787);
|
const port = Number(process.env.PORT || 8787);
|
||||||
const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media');
|
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 pairingSecret = process.env.PAIRING_SECRET;
|
||||||
const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH;
|
const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH;
|
||||||
const demoStreamUrl = process.env.DEMO_STREAM_URL;
|
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');
|
if (!pairingSecret && !pairingPasswordHash) throw new Error('PAIRING_PASSWORD_HASH or PAIRING_SECRET is required');
|
||||||
|
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
const tokens = new Map();
|
const loadTokens = () => {
|
||||||
const videos = () => [
|
try { return new Map(Object.entries(JSON.parse(fs.readFileSync(tokenPath, 'utf8')))); } catch { return new Map(); }
|
||||||
...(fs.existsSync(mediaRoot) ? fs.readdirSync(mediaRoot, { recursive: true })
|
};
|
||||||
.filter(file => /\.(mkv|mp4|webm|m4v)$/i.test(file))
|
const tokens = loadTokens();
|
||||||
.map(file => ({ id: Buffer.from(file).toString('base64url'), title: path.basename(file, path.extname(file)), type: 'video', description: file })) : []),
|
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' }] : []),
|
...(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 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 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 id = () => crypto.randomBytes(24).toString('base64url');
|
||||||
const passwordMatches = value => {
|
const passwordMatches = value => {
|
||||||
if (pairingPasswordHash) {
|
if (pairingPasswordHash) {
|
||||||
@@ -32,15 +48,31 @@ const passwordMatches = value => {
|
|||||||
const actual = crypto.scryptSync(value, salt, 32).toString('base64url');
|
const actual = crypto.scryptSync(value, salt, 32).toString('base64url');
|
||||||
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
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 => `<!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) => {
|
http.createServer(async (req, res) => {
|
||||||
const url = new URL(req.url, publicBaseUrl);
|
const url = new URL(req.url, publicBaseUrl);
|
||||||
console.log(`${req.method} ${url.pathname}`);
|
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, {
|
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'],
|
protocolVersion: 1, id: 'reference.home-library', name: 'Reference Home Library', capabilities: ['catalog', 'metadata', 'stream'],
|
||||||
endpoints: { catalog: '/v1/catalog', stream: '/v1/stream', pairStart: '/v1/pair/start', pairStatus: '/v1/pair/{sessionId}', pairToken: '/v1/pair/token' }
|
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') {
|
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' });
|
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 === '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 === '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 === '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 === '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: videos() }) : json(res, 401, { error: 'Unauthorized' });
|
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 === '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 === '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); }
|
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' });
|
json(res, 404, { error: 'Not found' });
|
||||||
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));
|
}).listen(port, () => console.log(`Connector listening on ${publicBaseUrl}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user