feat: add Cleanflix Connector Tauri desktop app
Move the Mac Tauri UI (pro.cleanflix.connector) into this repo under desktop/. Bundled Node runtime stays gitignored; package scripts fetch it when building.
@@ -1 +1,9 @@
|
||||
.env
|
||||
|
||||
# Tauri desktop
|
||||
desktop/node_modules/
|
||||
desktop/src-tauri/target/
|
||||
desktop/sidecar/node/
|
||||
desktop/dist/
|
||||
._*
|
||||
**/.DS_Store
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
sidecar/node/
|
||||
src-tauri/target/
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + Vanilla
|
||||
|
||||
This template should help get you started developing with Tauri in vanilla HTML, CSS and Javascript.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,232 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "desktop",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "desktop",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"prepare:sidecar": "sh scripts/prepare-sidecar.sh",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
OUT="$ROOT/desktop/sidecar"
|
||||
NODE_VERSION=${NODE_VERSION:-22.14.0}
|
||||
rm -rf "$OUT"
|
||||
mkdir -p "$OUT"
|
||||
cp "$ROOT/server.js" "$ROOT/library.js" "$ROOT/hash-password.js" "$ROOT/package.json" "$OUT/"
|
||||
cp -R "$ROOT/distribution/common" "$OUT/distribution-common"
|
||||
case "$(uname -s)-$(uname -m)" in
|
||||
Darwin-arm64) ARCHIVE="node-v$NODE_VERSION-darwin-arm64" ;;
|
||||
Darwin-x86_64) ARCHIVE="node-v$NODE_VERSION-darwin-x64" ;;
|
||||
Linux-x86_64) ARCHIVE="node-v$NODE_VERSION-linux-x64" ;;
|
||||
*) echo "Unsupported build platform" >&2; exit 1 ;;
|
||||
esac
|
||||
curl -fsSL "https://nodejs.org/dist/v$NODE_VERSION/$ARCHIVE.tar.gz" | tar -xz -C "$OUT"
|
||||
mv "$OUT/$ARCHIVE" "$OUT/node"
|
||||
"$OUT/node/bin/npm" install --omit=dev --prefix "$OUT"
|
||||
@@ -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,7 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const password = process.argv[2];
|
||||
if (!password) throw new Error('Usage: node hash-password.js <password>');
|
||||
const salt = crypto.randomBytes(16).toString('base64url');
|
||||
const hash = crypto.scryptSync(password, salt, 32).toString('base64url');
|
||||
console.log(`${salt}:${hash}`);
|
||||
@@ -0,0 +1,197 @@
|
||||
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 firstText = value => Array.isArray(value)
|
||||
? value.find(item => typeof item === 'string' && item.trim()) || ''
|
||||
: typeof value === 'string' ? value : '';
|
||||
const year = value => {
|
||||
const parsed = Number(Array.isArray(value) ? value[0] : value);
|
||||
return Number.isInteger(parsed) ? parsed : null;
|
||||
};
|
||||
const mapWithConcurrency = async (items, limit, mapper) => {
|
||||
const results = new Array(items.length);
|
||||
let next = 0;
|
||||
const worker = async () => {
|
||||
while (next < items.length) {
|
||||
const index = next++;
|
||||
results[index] = await mapper(items[index]);
|
||||
}
|
||||
};
|
||||
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
|
||||
return results;
|
||||
};
|
||||
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();
|
||||
let catalogCache = null;
|
||||
let catalogRefresh = null;
|
||||
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)
|
||||
? (await fs.promises.readdir(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 = await mapWithConcurrency(files, 16, async file => {
|
||||
const stat = await fs.promises.stat(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: firstText(mapping.title) || firstText(nfo.title) || firstText(parsed.title) || path.basename(file, path.extname(file)),
|
||||
year: year(mapping.year) || year(nfo.year) || year(parsed.year),
|
||||
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;
|
||||
};
|
||||
const refreshCatalog = async () => {
|
||||
if (catalogRefresh) return catalogRefresh;
|
||||
catalogRefresh = (async () => {
|
||||
const items = await Promise.all((await scan()).map(metadataFor));
|
||||
catalogCache = items;
|
||||
return items;
|
||||
})().finally(() => { catalogRefresh = null; });
|
||||
return catalogRefresh;
|
||||
};
|
||||
return {
|
||||
scan,
|
||||
startReconciler(seconds = 900) {
|
||||
const refresh = () => refreshCatalog().catch(error => console.error('Library reconciliation failed', error));
|
||||
// Let the server start before scanning a large remote-backed library.
|
||||
setImmediate(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 catalogCache || refreshCatalog();
|
||||
},
|
||||
async metadata(id) {
|
||||
return (await this.catalog()).find(item => item.id === id) || null;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "cleanstream-media-connector-reference",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cleanstream-media-connector-reference",
|
||||
"dependencies": {
|
||||
"guessit-js": "^4.0.0",
|
||||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@leichtgewicht/ip-codec": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz",
|
||||
"integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dns-packet": {
|
||||
"version": "5.6.1",
|
||||
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-5.6.1.tgz",
|
||||
"integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@leichtgewicht/ip-codec": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/guessit-js": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/guessit-js/-/guessit-js-4.5.0.tgz",
|
||||
"integrity": "sha512-S/HPInVu+UcIjjO2SVZXMzbwr6WovS9K4wAzH3Tx7ZTS8xQ+m3DHxjBfgyjf4L90cY6Wb2owQNEO2ZJNML6wpQ==",
|
||||
"license": "LGPL-3.0",
|
||||
"dependencies": {
|
||||
"rebulk-js": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/multicast-dns": {
|
||||
"version": "7.2.5",
|
||||
"resolved": "https://registry.npmjs.org/multicast-dns/-/multicast-dns-7.2.5.tgz",
|
||||
"integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dns-packet": "^5.2.2",
|
||||
"thunky": "^1.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"multicast-dns": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/rebulk-js": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/rebulk-js/-/rebulk-js-3.3.1.tgz",
|
||||
"integrity": "sha512-xohP5vyrQoFFCyhNdK7CNtXRf87SkyB/1kfJHOUw8XiSGxFgFRI8QAMt8RGboCQQR+1mnbLJy28oJJrYrXXW+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/thunky": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/thunky/-/thunky-1.1.0.tgz",
|
||||
"integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "cleanstream-media-connector-reference",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"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",
|
||||
"multicast-dns": "^7.2.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import multicastDns from 'multicast-dns';
|
||||
import { createLibrary } from './library.js';
|
||||
|
||||
const port = Number(process.env.PORT || 8787);
|
||||
const mediaRoot = path.resolve(process.env.MEDIA_ROOT || './media');
|
||||
const publicBaseUrl = (process.env.PUBLIC_BASE_URL || `http://localhost:${port}`).replace(/\/$/, '');
|
||||
const approvalBaseUrl = (process.env.APPROVAL_BASE_URL || publicBaseUrl).replace(/\/$/, '');
|
||||
const cleanflixApiUrl = process.env.CLEANFLIX_API_URL?.replace(/\/$/, '');
|
||||
const pairingSecret = process.env.PAIRING_SECRET;
|
||||
const pairingPasswordHash = process.env.PAIRING_PASSWORD_HASH;
|
||||
const demoStreamUrl = process.env.DEMO_STREAM_URL;
|
||||
const catalogLimit = Math.max(0, Number(process.env.CATALOG_LIMIT || 0));
|
||||
const mdnsHostname = new URL(publicBaseUrl).hostname;
|
||||
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 accountLinkSessions = new Map();
|
||||
const accountTokenLifetime = 365 * 24 * 60 * 60 * 1000;
|
||||
const loadTokens = () => {
|
||||
try { return new Map(Object.entries(JSON.parse(fs.readFileSync(tokenPath, 'utf8')))); } catch { return new Map(); }
|
||||
};
|
||||
const tokens = loadTokens();
|
||||
const localAddress = () => Object.values(os.networkInterfaces()).flat().find(address =>
|
||||
address && address.family === 'IPv4' && !address.internal,
|
||||
)?.address;
|
||||
const advertiseConnector = () => {
|
||||
if (!mdnsHostname.endsWith('.local')) return () => {};
|
||||
const address = localAddress();
|
||||
if (!address) return () => {};
|
||||
const mdns = multicastDns();
|
||||
const hostname = `${mdnsHostname}.`;
|
||||
const service = 'Cleanflix Connector._cleanflix-connector._tcp.local.';
|
||||
const records = [
|
||||
{ name: hostname, type: 'A', ttl: 120, data: address },
|
||||
{ name: '_cleanflix-connector._tcp.local.', type: 'PTR', ttl: 120, data: service },
|
||||
{ name: service, type: 'SRV', ttl: 120, data: { port, target: hostname } },
|
||||
{ name: service, type: 'TXT', ttl: 120, data: ['path=/'] },
|
||||
];
|
||||
const respond = () => mdns.respond(records);
|
||||
mdns.on('query', query => {
|
||||
if (query.questions.some(question => question.name === hostname || question.name === '_cleanflix-connector._tcp.local.')) respond();
|
||||
});
|
||||
respond();
|
||||
const interval = setInterval(respond, 60_000);
|
||||
console.log(`mDNS available at http://${mdnsHostname}:${port}`);
|
||||
return () => { clearInterval(interval); mdns.destroy(); };
|
||||
};
|
||||
const saveTokens = () => {
|
||||
fs.mkdirSync(path.dirname(tokenPath), { recursive: true });
|
||||
fs.writeFileSync(tokenPath, `${JSON.stringify(Object.fromEntries(tokens))}\n`, { mode: 0o600 });
|
||||
};
|
||||
const videos = async () => {
|
||||
const items = (await library.catalog()).map(item => ({ ...item, imdbId: item.imdbId || item.suggestedImdbId, description: item.description || item.file }));
|
||||
const limited = catalogLimit > 0 ? items.slice(0, catalogLimit) : items;
|
||||
return [...limited, ...(demoStreamUrl ? [{ id: 'demo-stream', title: 'Connector playback test', type: 'video', description: 'Staging test stream' }] : [])];
|
||||
};
|
||||
// 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 => {
|
||||
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) {
|
||||
const [salt, expected] = pairingPasswordHash.split(':');
|
||||
if (!salt || !expected) return false;
|
||||
const actual = crypto.scryptSync(value, salt, 32).toString('base64url');
|
||||
return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected));
|
||||
}
|
||||
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 accountLinkPage = (title, message) => `<!doctype html><title>${html(title)}</title><style>body{font:16px system-ui;max-width:38rem;margin:4rem auto;padding:0 1rem}h1{margin-bottom:.5rem}</style><h1>${html(title)}</h1><p>${html(message)}</p>`;
|
||||
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('')}`;
|
||||
|
||||
const server = 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 === '/account/link') {
|
||||
if (!cleanflixApiUrl) return json(res, 503, { error: 'CLEANFLIX_API_URL is required for account linking' });
|
||||
const state = url.searchParams.get('state');
|
||||
const linkToken = url.searchParams.get('linkToken');
|
||||
if (!state && !linkToken) {
|
||||
const linkState = id();
|
||||
accountLinkSessions.set(linkState, { expires: Date.now() + 600000 });
|
||||
const handoff = new URL('/connector/link', cleanflixApiUrl);
|
||||
handoff.searchParams.set('returnUrl', `${publicBaseUrl}/account/link?state=${encodeURIComponent(linkState)}`);
|
||||
handoff.searchParams.set('connectorUrl', publicBaseUrl);
|
||||
res.writeHead(302, { location: handoff.toString() });
|
||||
return res.end();
|
||||
}
|
||||
const session = state && accountLinkSessions.get(state);
|
||||
accountLinkSessions.delete(state);
|
||||
if (!session || session.expires < Date.now() || !linkToken) {
|
||||
res.writeHead(400, { 'content-type': 'text/html; charset=utf-8' });
|
||||
return res.end(accountLinkPage('Account link failed', 'This account-link request is invalid or has expired. Start again from your connector.'));
|
||||
}
|
||||
const accessToken = id();
|
||||
const expiresAt = Date.now() + accountTokenLifetime;
|
||||
tokens.set(accessToken, expiresAt);
|
||||
saveTokens();
|
||||
try {
|
||||
const response = await fetch(`${cleanflixApiUrl}/api/sync/connector/link`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ linkToken, connectorUrl: publicBaseUrl, accessToken, expiresAt: new Date(expiresAt).toISOString() }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Cleanflix returned ${response.status}`);
|
||||
} catch (error) {
|
||||
tokens.delete(accessToken);
|
||||
saveTokens();
|
||||
console.error(`Account link completion failed: ${error.message}`);
|
||||
res.writeHead(502, { 'content-type': 'text/html; charset=utf-8' });
|
||||
return res.end(accountLinkPage('Account link failed', 'Cleanflix could not complete the account link. Please start again.'));
|
||||
}
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
||||
return res.end(accountLinkPage('Account linked', 'This connector is now linked to your Cleanflix account. You can close this window.'));
|
||||
}
|
||||
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', 'metadata', 'stream'],
|
||||
endpoints: { catalog: '/v1/catalog', metadata: '/v1/metadata', stream: '/v1/stream', pairStart: '/v1/pair/start', pairStatus: '/v1/pair/{sessionId}', pairToken: '/v1/pair/token', accountLink: '/account/link' }
|
||||
});
|
||||
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 sessionId = id(); sessions.set(sessionId, { challenge: body.codeChallenge, expires: Date.now() + 300000 });
|
||||
return json(res, 200, { sessionId, approvalUri: `${approvalBaseUrl}/pair/approve?session=${sessionId}`, expiresAt: new Date(Date.now() + 300000).toISOString(), pollIntervalSeconds: 2 });
|
||||
}
|
||||
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); 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' });
|
||||
const size = fs.statSync(target).size;
|
||||
const match = req.headers.range?.match(/^bytes=(\d*)-(\d*)$/);
|
||||
const start = Number(match?.[1] || 0);
|
||||
const end = Math.min(Number(match?.[2] || size - 1), size - 1);
|
||||
if (match && (start > end || start >= size)) {
|
||||
res.writeHead(416, { 'content-range': `bytes */${size}` });
|
||||
return res.end();
|
||||
}
|
||||
const headers = { 'content-type': 'application/octet-stream', 'accept-ranges': 'bytes', 'content-length': end - start + 1 };
|
||||
if (match) headers['content-range'] = `bytes ${start}-${end}/${size}`;
|
||||
res.writeHead(match ? 206 : 200, headers);
|
||||
const stream = fs.createReadStream(target, { start, end });
|
||||
stream.on('error', error => {
|
||||
console.error(`Media read failed: ${error.message}`);
|
||||
if (!res.headersSent) json(res, 502, { error: 'Media read failed' });
|
||||
else res.destroy(error);
|
||||
});
|
||||
return stream.pipe(res);
|
||||
}
|
||||
json(res, 404, { error: 'Not found' });
|
||||
});
|
||||
server.listen(port, process.env.HOST, () => {
|
||||
const stopAdvertising = advertiseConnector();
|
||||
server.once('close', stopAdvertising);
|
||||
console.log(`Connector listening on ${publicBaseUrl}`);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "desktop"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix may seem redundant but it is necessary
|
||||
# to make the lib name unique and wouldn't conflict with the bin name.
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
rfd = "0.15"
|
||||
if-addrs = "0.13"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,143 @@
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use if_addrs::{get_if_addrs, IfAddr};
|
||||
use tauri::Manager;
|
||||
|
||||
const CONNECTOR_PORT: u16 = 8791;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectorConfig { media_root: String, pairing_password: String }
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ConnectorSetup { media_root: String, password_configured: bool, public_base_url: String }
|
||||
|
||||
fn public_base_url() -> Result<String, String> {
|
||||
let address = get_if_addrs().map_err(|_| "Could not determine this computer's LAN address.".to_string())?
|
||||
.into_iter()
|
||||
.filter_map(|interface| match interface.addr {
|
||||
IfAddr::V4(address) if address.ip.is_private() && !matches!(address.ip.octets()[3], 0 | 255) => Some((interface.name, address.ip)),
|
||||
_ => None,
|
||||
})
|
||||
.filter(|(name, _)| !["bridge", "docker", "veth", "utun", "lo"].iter().any(|prefix| name.starts_with(prefix)))
|
||||
// Prefer common home-LAN subnets over container bridge networks.
|
||||
.max_by_key(|(name, address)| (
|
||||
if name.starts_with("en") { 1 } else { 0 },
|
||||
match address.octets() {
|
||||
[192, 168, ..] => 3,
|
||||
[172, second, ..] if (16..=31).contains(&second) => 2,
|
||||
[10, ..] => 1,
|
||||
_ => 0,
|
||||
},
|
||||
))
|
||||
.map(|(_, address)| address)
|
||||
.ok_or_else(|| "Connect this computer to your home network first.".to_string())?;
|
||||
Ok(format!("http://{address}:{CONNECTOR_PORT}"))
|
||||
}
|
||||
|
||||
fn connector_command(app: &tauri::AppHandle) -> Result<Command, String> {
|
||||
let resource_dir = app.path().resource_dir().map_err(|e| e.to_string())?.join("sidecar");
|
||||
let config = app.path().app_data_dir().map_err(|e| e.to_string())?.join("config.env");
|
||||
let mut command = Command::new(resource_dir.join("distribution-common/start-connector.sh"));
|
||||
command
|
||||
.env("CLEANFLIX_APP_ROOT", &resource_dir)
|
||||
.env("CLEANFLIX_CONFIG_PATH", config)
|
||||
.env("CLEANFLIX_NODE", resource_dir.join("node/bin/node"))
|
||||
// TVs discover the connector through an IPv4 LAN address.
|
||||
.env("HOST", "0.0.0.0")
|
||||
.env("PORT", CONNECTOR_PORT.to_string());
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pick_media_folder() -> Option<String> {
|
||||
rfd::FileDialog::new().pick_folder().map(|path| path.display().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_connector_setup(app: tauri::AppHandle) -> Result<ConnectorSetup, String> {
|
||||
let config = app.path().app_data_dir().map_err(|e| e.to_string())?.join("config.env");
|
||||
let content = fs::read_to_string(config).unwrap_or_default();
|
||||
let value = |key: &str| content.lines()
|
||||
.find_map(|line| line.strip_prefix(&format!("{key}=")))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
Ok(ConnectorSetup {
|
||||
media_root: value("MEDIA_ROOT"),
|
||||
password_configured: !value("PAIRING_PASSWORD_HASH").is_empty(),
|
||||
public_base_url: value("PUBLIC_BASE_URL"),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_cleanflix_link_url(app: tauri::AppHandle) -> Result<String, String> {
|
||||
let setup = get_connector_setup(app)?;
|
||||
if setup.media_root.is_empty() || !setup.password_configured {
|
||||
return Err("Save your media folder and pairing password before signing in.".into());
|
||||
}
|
||||
if setup.public_base_url.is_empty() { return Err("Start the connector before signing in.".into()); }
|
||||
Ok(format!("{}/account/link", setup.public_base_url))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn save_connector_config(app: tauri::AppHandle, config: ConnectorConfig) -> Result<(), String> {
|
||||
if config.media_root.trim().is_empty() {
|
||||
return Err("Choose a media folder.".into());
|
||||
}
|
||||
let dir = app.path().app_data_dir().map_err(|e| e.to_string())?;
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let config_path = dir.join("config.env");
|
||||
let existing = fs::read_to_string(&config_path).unwrap_or_default();
|
||||
let existing_hash = existing.lines().find_map(|line| line.strip_prefix("PAIRING_PASSWORD_HASH=")).unwrap_or_default();
|
||||
let resource_dir = app.path().resource_dir().map_err(|e| e.to_string())?.join("sidecar");
|
||||
let password_hash = if config.pairing_password.is_empty() {
|
||||
if existing_hash.is_empty() { return Err("Choose a password of at least 12 characters.".into()); }
|
||||
existing_hash.to_string()
|
||||
} else {
|
||||
if config.pairing_password.len() < 12 { return Err("Choose a password of at least 12 characters.".into()); }
|
||||
let hash = Command::new(resource_dir.join("node/bin/node"))
|
||||
.arg("hash-password.js")
|
||||
.arg(&config.pairing_password)
|
||||
.current_dir(&resource_dir)
|
||||
.output()
|
||||
.map_err(|_| "The bundled connector runtime is unavailable.".to_string())?;
|
||||
if !hash.status.success() { return Err("Could not secure the pairing password.".into()); }
|
||||
String::from_utf8(hash.stdout).map_err(|e| e.to_string())?.trim().to_string()
|
||||
};
|
||||
let public_base_url = public_base_url()?;
|
||||
let content = format!("MEDIA_ROOT={}\nPUBLIC_BASE_URL={}\nAPPROVAL_BASE_URL={}\nCLEANFLIX_API_URL=https://cleanflix.pro\nPAIRING_PASSWORD_HASH={}\n", config.media_root, public_base_url, public_base_url, password_hash);
|
||||
fs::write(config_path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn start_connector(app: tauri::AppHandle) -> Result<(), String> {
|
||||
connector_command(&app)?.spawn().map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![pick_media_folder, get_connector_setup, get_cleanflix_link_url, save_connector_config, start_connector])
|
||||
.setup(|app| {
|
||||
let config = app.path().app_data_dir()?.join("config.env");
|
||||
if config.exists() {
|
||||
let content = fs::read_to_string(&config)?;
|
||||
let mut updated = content.lines().map(|line| {
|
||||
if line.starts_with("PUBLIC_BASE_URL=") || line.starts_with("APPROVAL_BASE_URL=") {
|
||||
format!("{}={}",&line[..line.find('=').unwrap()], public_base_url().unwrap_or_else(|_| "http://cleanflix-connector.local:8787".into()))
|
||||
} else { line.to_string() }
|
||||
}).collect::<Vec<_>>();
|
||||
if !updated.iter().any(|line| line.starts_with("CLEANFLIX_API_URL=")) {
|
||||
updated.push("CLEANFLIX_API_URL=https://cleanflix.pro".into());
|
||||
}
|
||||
fs::write(&config, format!("{}\n", updated.join("\n")))?;
|
||||
connector_command(&app.handle()).map_err(std::io::Error::other)?.spawn()?;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running Tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cleanflix Connector",
|
||||
"version": "0.1.0",
|
||||
"identifier": "pro.cleanflix.connector",
|
||||
"build": {
|
||||
"frontendDist": "../src"
|
||||
},
|
||||
"app": {
|
||||
"withGlobalTauri": true,
|
||||
"windows": [
|
||||
{
|
||||
"title": "Cleanflix Connector",
|
||||
"width": 900,
|
||||
"height": 780,
|
||||
"minHeight": 700
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": {"../sidecar": "sidecar"},
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 162 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Cleanflix Connector</title><link rel="stylesheet" href="styles.css"><script type="module" src="/main.js" defer></script></head><body><main><p class="eyebrow">Cleanflix Connector</p><h1>Your library.<br>Your TV.</h1><p class="lead">Choose the folder you want to share. Cleanflix finds this connector automatically on your home network.</p><form id="connector-form"><label>Media folder<span class="folder"><input name="mediaRoot" placeholder="Choose a folder" required readonly><button type="button" id="browse-folder">Browse</button></span></label><label>Pairing password<input name="pairingPassword" type="password" placeholder="Choose a private password"></label><button>Start connector</button></form><button id="link-account" class="secondary" type="button">Sign in to Cleanflix</button><p id="status" role="status"></p></main></body></html>
|
||||
@@ -0,0 +1,37 @@
|
||||
const form = document.querySelector('#connector-form');
|
||||
const status = document.querySelector('#status');
|
||||
const mediaRoot = form.elements.namedItem('mediaRoot');
|
||||
const pairingPassword = form.elements.namedItem('pairingPassword');
|
||||
const linkAccount = document.querySelector('#link-account');
|
||||
|
||||
document.querySelector('#browse-folder').addEventListener('click', async () => {
|
||||
const folder = await window.__TAURI__.core.invoke('pick_media_folder');
|
||||
if (folder) mediaRoot.value = folder;
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
status.textContent = 'Saving your connector setup…';
|
||||
const config = Object.fromEntries(new FormData(form));
|
||||
try {
|
||||
await window.__TAURI__.core.invoke('save_connector_config', { config });
|
||||
localStorage.setItem('cleanflix.connector.mediaRoot', config.mediaRoot);
|
||||
await window.__TAURI__.core.invoke('start_connector');
|
||||
status.textContent = 'Saved securely. The connector is running and ready to pair.';
|
||||
} catch (error) { status.textContent = error; }
|
||||
});
|
||||
|
||||
linkAccount.addEventListener('click', async () => {
|
||||
try {
|
||||
const linkUrl = await window.__TAURI__.core.invoke('get_cleanflix_link_url');
|
||||
await window.__TAURI__.opener.openUrl(linkUrl);
|
||||
status.textContent = 'Finish signing in with Cleanflix in your browser.';
|
||||
} catch (error) { status.textContent = error; }
|
||||
});
|
||||
|
||||
const setup = await window.__TAURI__.core.invoke('get_connector_setup');
|
||||
if (setup.mediaRoot) mediaRoot.value = setup.mediaRoot;
|
||||
if (setup.passwordConfigured) {
|
||||
pairingPassword.placeholder = 'Password already set (enter only to change it)';
|
||||
status.textContent = 'Saved connector setup is running.';
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,system-ui;background:#000;color:#fff}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:#000 url('./assets/connector-background.jpg') center/cover no-repeat}main{max-width:720px;min-height:100vh;padding:88px 10vw;text-shadow:0 2px 18px #000}.eyebrow{color:#e50914;font-weight:800;letter-spacing:.16em;text-transform:uppercase;font-size:12px}h1{font-size:clamp(48px,8vw,82px);line-height:.92;letter-spacing:-.07em;margin:18px 0}.lead{color:#c9c9c9;font-size:18px;max-width:520px}form{display:grid;gap:16px;margin-top:42px;max-width:520px}label{display:grid;gap:7px;font-weight:700}.folder{display:flex;gap:8px}.folder input{min-width:0;flex:1}input{background:rgb(12 12 12 / .86);border:1px solid #555;border-radius:9px;color:#fff;padding:14px;font:inherit}button{background:#e50914;border:0;border-radius:9px;color:#fff;font:700 16px inherit;padding:15px;cursor:pointer}button:hover{background:#f6121d}#browse-folder,.secondary{background:rgb(32 32 32 / .9);white-space:nowrap}.secondary{display:block;margin-top:12px;width:100%;border:1px solid #666}#status{color:#d0d0d0;margin-top:18px}@media(max-width:640px){body{background-position:62% center}main{padding:56px 24px}.folder{display:grid}.folder input{width:100%}}
|
||||