initial commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
.git
|
||||
.env
|
||||
coverage
|
||||
80
.gitea/workflows/docker-latest.yml
Normal file
80
.gitea/workflows/docker-latest.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
name: Build & Push Docker (latest)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm ci
|
||||
|
||||
- name: Typecheck
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run typecheck
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm test
|
||||
|
||||
- name: Build app
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
npm run build
|
||||
|
||||
build:
|
||||
needs: verify
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set image name
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
OWNER="${GITHUB_REPOSITORY%/*}"
|
||||
REPO="${GITHUB_REPOSITORY#*/}"
|
||||
echo "IMAGE=git.f-40.com/${OWNER}/${REPO}:latest" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Login to Gitea Container Registry
|
||||
shell: bash
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.USER }}
|
||||
REGISTRY_TOKEN: ${{ secrets.TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "$REGISTRY_TOKEN" | docker login git.f-40.com -u "$REGISTRY_USER" --password-stdin
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker build -t "$IMAGE" .
|
||||
|
||||
- name: Push
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker push "$IMAGE"
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:22-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
COPY public ./public
|
||||
COPY src ./src
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "start"]
|
||||
57
README.md
57
README.md
@@ -1,3 +1,58 @@
|
||||
# oembed-graphics
|
||||
|
||||
Embed social media posts using Oembed in a format that's callable from Caspar/OGraf
|
||||
Docker-hosted oEmbed graphics for broadcast workflows. It is inspired by
|
||||
[webrecorder/oembed.link](https://github.com/webrecorder/oembed.link), but runs
|
||||
as a normal container and exposes graphic URLs that can be loaded by CasparCG,
|
||||
OBS Browser Source, OGraf, or any HTML-capable character generator.
|
||||
|
||||
## Run locally
|
||||
|
||||
```sh
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Open `http://localhost:3000`.
|
||||
|
||||
## Run with Docker
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
The service listens on `http://localhost:3000`.
|
||||
|
||||
## Broadcast URL
|
||||
|
||||
Use `/graphic` with a source URL:
|
||||
|
||||
```txt
|
||||
http://localhost:3000/graphic?url=https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ&width=1920&height=1080&transparent=1
|
||||
```
|
||||
|
||||
Useful query parameters:
|
||||
|
||||
- `url`: required source URL to resolve through oEmbed.
|
||||
- `width`: stage width, default `1920`.
|
||||
- `height`: stage height, default `1080`.
|
||||
- `transparent`: `1` for transparent output, default `1`.
|
||||
- `chroma`: background color when transparent output is disabled, default `#00ff00`.
|
||||
- `fit`: `contain` or `cover`, default `contain`.
|
||||
- `scale`: graphic scale multiplier, default `1`.
|
||||
- `maxwidth`: sent to the oEmbed provider, default is based on stage width.
|
||||
|
||||
The service also supports the oembed.link-style form:
|
||||
|
||||
```txt
|
||||
http://localhost:3000/https%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3DdQw4w9WgXcQ
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /api/oembed?url=...` returns the matched provider and raw oEmbed data.
|
||||
- `GET /providers` returns the loaded provider patterns.
|
||||
- `GET /healthz` returns a health check response.
|
||||
|
||||
Provider data is loaded from `https://oembed.com/providers.json` and cached in
|
||||
memory. Override with `PROVIDERS_URL`, `PROVIDERS_TTL_MS`, and
|
||||
`OEMBED_TIMEOUT_MS` environment variables.
|
||||
|
||||
11
docker-compose.yml
Normal file
11
docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
oembed-graphics:
|
||||
build: .
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
PORT: "3000"
|
||||
HOST: "0.0.0.0"
|
||||
PROVIDERS_TTL_MS: "43200000"
|
||||
OEMBED_TIMEOUT_MS: "8000"
|
||||
restart: unless-stopped
|
||||
16
package-lock.json
generated
Normal file
16
package-lock.json
generated
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "oembed-graphics",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "oembed-graphics",
|
||||
"version": "0.1.0",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "oembed-graphics",
|
||||
"version": "0.1.0",
|
||||
"description": "Docker-hosted oEmbed graphics service for broadcast overlays.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node --watch src/server.js",
|
||||
"start": "node src/server.js",
|
||||
"typecheck": "node --check src/server.js && node --check src/oembed.js && node --check src/providers.js && node --check src/templates.js",
|
||||
"build": "npm run typecheck",
|
||||
"test": "node --test"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later"
|
||||
}
|
||||
177
public/styles.css
Normal file
177
public/styles.css
Normal file
@@ -0,0 +1,177 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: #f4f6f8;
|
||||
color: #1d252d;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.app {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(920px, 100%);
|
||||
}
|
||||
|
||||
.panel {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
padding: 28px;
|
||||
border: 1px solid #d9e0e7;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 14px 38px rgb(18 28 45 / 10%);
|
||||
}
|
||||
|
||||
h1,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 28px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
form {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button {
|
||||
min-height: 42px;
|
||||
border: 1px solid #c8d2dc;
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0 18px;
|
||||
border-color: #153f66;
|
||||
background: #153f66;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.controls label:last-child {
|
||||
display: flex;
|
||||
min-height: 42px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.controls input[type="checkbox"] {
|
||||
width: 16px;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: "Cascadia Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
.graphic {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
background: var(--chroma);
|
||||
}
|
||||
|
||||
.graphic.transparent {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.stage {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: min(100vw, var(--stage-width));
|
||||
height: min(100vh, var(--stage-height));
|
||||
margin: 0 auto;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embed {
|
||||
display: grid;
|
||||
max-width: 92%;
|
||||
max-height: 92%;
|
||||
place-items: center;
|
||||
transform: scale(var(--scale));
|
||||
transform-origin: center;
|
||||
}
|
||||
|
||||
.embed > iframe,
|
||||
.embed > blockquote,
|
||||
.embed > img,
|
||||
.embed > video {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.fit-cover .embed > iframe,
|
||||
.fit-cover .embed > img,
|
||||
.fit-cover .embed > video {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.error {
|
||||
border-color: #d77;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.row,
|
||||
.controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
75
src/oembed.js
Normal file
75
src/oembed.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import { findProvider, loadProviders } from "./providers.js";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 8000;
|
||||
|
||||
export function buildOembedUrl(endpoint, targetUrl, maxWidth) {
|
||||
const requestUrl = new URL(endpoint);
|
||||
requestUrl.searchParams.set("url", targetUrl);
|
||||
requestUrl.searchParams.set("format", "json");
|
||||
|
||||
if (maxWidth) {
|
||||
requestUrl.searchParams.set("maxwidth", String(maxWidth));
|
||||
}
|
||||
|
||||
return requestUrl;
|
||||
}
|
||||
|
||||
export async function fetchOembed(targetUrl, {
|
||||
fetchImpl = fetch,
|
||||
providers,
|
||||
timeoutMs = Number(process.env.OEMBED_TIMEOUT_MS || DEFAULT_TIMEOUT_MS),
|
||||
maxWidth,
|
||||
} = {}) {
|
||||
let parsed;
|
||||
|
||||
try {
|
||||
parsed = new URL(targetUrl);
|
||||
} catch {
|
||||
const error = new Error("The url parameter must be an absolute URL.");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
const error = new Error("Only http and https URLs are supported.");
|
||||
error.status = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const availableProviders = providers || await loadProviders({ fetchImpl });
|
||||
const provider = findProvider(targetUrl, availableProviders);
|
||||
|
||||
if (!provider) {
|
||||
const error = new Error("No oEmbed provider matched this URL.");
|
||||
error.status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(buildOembedUrl(provider.endpoint, targetUrl, maxWidth), {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "oembed-graphics/0.1",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`oEmbed provider returned ${response.status}.`);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
provider,
|
||||
data,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
83
src/providers.js
Normal file
83
src/providers.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const DEFAULT_PROVIDERS_URL = "https://oembed.com/providers.json";
|
||||
const DEFAULT_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
let providerCache = {
|
||||
expiresAt: 0,
|
||||
providers: [],
|
||||
};
|
||||
|
||||
export function wildcardToRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
export function normalizeEndpoint(endpoint) {
|
||||
return endpoint.replace("{format}", "json");
|
||||
}
|
||||
|
||||
export function flattenProviders(rawProviders) {
|
||||
const providers = [];
|
||||
|
||||
for (const provider of rawProviders) {
|
||||
for (const endpoint of provider.endpoints || []) {
|
||||
const schemes = endpoint.schemes || [];
|
||||
|
||||
for (const scheme of schemes) {
|
||||
providers.push({
|
||||
providerName: provider.provider_name,
|
||||
providerUrl: provider.provider_url,
|
||||
endpoint: normalizeEndpoint(endpoint.url),
|
||||
scheme,
|
||||
regex: wildcardToRegExp(scheme),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function findProvider(url, providers) {
|
||||
return providers.find((provider) => provider.regex.test(url));
|
||||
}
|
||||
|
||||
export async function loadProviders({
|
||||
providersUrl = process.env.PROVIDERS_URL || DEFAULT_PROVIDERS_URL,
|
||||
ttlMs = Number(process.env.PROVIDERS_TTL_MS || DEFAULT_TTL_MS),
|
||||
fetchImpl = fetch,
|
||||
force = false,
|
||||
} = {}) {
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && providerCache.providers.length && providerCache.expiresAt > now) {
|
||||
return providerCache.providers;
|
||||
}
|
||||
|
||||
const response = await fetchImpl(providersUrl, {
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
"user-agent": "oembed-graphics/0.1",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not load providers: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const rawProviders = await response.json();
|
||||
const providers = flattenProviders(rawProviders);
|
||||
|
||||
providerCache = {
|
||||
expiresAt: now + ttlMs,
|
||||
providers,
|
||||
};
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export function clearProviderCache() {
|
||||
providerCache = {
|
||||
expiresAt: 0,
|
||||
providers: [],
|
||||
};
|
||||
}
|
||||
160
src/server.js
Normal file
160
src/server.js
Normal file
@@ -0,0 +1,160 @@
|
||||
import { createServer } from "node:http";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { extname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { fetchOembed } from "./oembed.js";
|
||||
import { loadProviders } from "./providers.js";
|
||||
import { errorPage, graphicPage, homePage } from "./templates.js";
|
||||
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
const rootDir = join(__dirname, "..");
|
||||
const publicDir = join(rootDir, "public");
|
||||
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
const host = process.env.HOST || "0.0.0.0";
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
};
|
||||
|
||||
function send(response, status, body, contentType = "text/html; charset=utf-8") {
|
||||
response.writeHead(status, {
|
||||
"content-type": contentType,
|
||||
"cache-control": status === 200 ? "public, max-age=60" : "no-store",
|
||||
"x-content-type-options": "nosniff",
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
function sendJson(response, status, body) {
|
||||
send(response, status, JSON.stringify(body, null, 2), "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
function getBoolean(searchParams, key, defaultValue = false) {
|
||||
if (!searchParams.has(key)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return ["1", "true", "yes", "on"].includes(searchParams.get(key).toLowerCase());
|
||||
}
|
||||
|
||||
function getNumber(searchParams, key, defaultValue, min, max) {
|
||||
const value = Number(searchParams.get(key) || defaultValue);
|
||||
|
||||
if (!Number.isFinite(value)) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
async function serveStatic(requestUrl, response) {
|
||||
const path = requestUrl.pathname === "/styles.css" ? "styles.css" : "";
|
||||
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const body = await readFile(join(publicDir, path), "utf8");
|
||||
send(response, 200, body, contentTypes[extname(path)] || "text/plain; charset=utf-8");
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleGraphic(requestUrl, response) {
|
||||
const url = requestUrl.searchParams.get("url") || decodeURIComponent(requestUrl.pathname.replace(/^\/+/, ""));
|
||||
|
||||
if (!url) {
|
||||
send(response, 400, errorPage("Add a url query parameter, for example /graphic?url=https://...", 400), "text/html; charset=utf-8");
|
||||
return;
|
||||
}
|
||||
|
||||
const width = getNumber(requestUrl.searchParams, "width", 1920, 320, 3840);
|
||||
const height = getNumber(requestUrl.searchParams, "height", 1080, 240, 2160);
|
||||
const scale = getNumber(requestUrl.searchParams, "scale", 1, 0.25, 3);
|
||||
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", Math.round(width * 0.72), 240, 3840);
|
||||
const fit = requestUrl.searchParams.get("fit") === "cover" ? "cover" : "contain";
|
||||
const transparent = getBoolean(requestUrl.searchParams, "transparent", true);
|
||||
const chroma = requestUrl.searchParams.get("chroma") || "#00ff00";
|
||||
|
||||
const { data } = await fetchOembed(url, { maxWidth });
|
||||
|
||||
send(response, 200, graphicPage({
|
||||
targetUrl: url,
|
||||
embed: data,
|
||||
width,
|
||||
height,
|
||||
fit,
|
||||
transparent,
|
||||
scale,
|
||||
chroma,
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleRequest(request, response) {
|
||||
const requestUrl = new URL(request.url, `http://${request.headers.host || "localhost"}`);
|
||||
|
||||
try {
|
||||
if (await serveStatic(requestUrl, response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/healthz") {
|
||||
sendJson(response, 200, { ok: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/providers") {
|
||||
const providers = await loadProviders();
|
||||
sendJson(response, 200, {
|
||||
count: providers.length,
|
||||
providers: providers.map(({ providerName, providerUrl, endpoint, scheme }) => ({
|
||||
providerName,
|
||||
providerUrl,
|
||||
endpoint,
|
||||
scheme,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/api/oembed") {
|
||||
const url = requestUrl.searchParams.get("url");
|
||||
const maxWidth = getNumber(requestUrl.searchParams, "maxwidth", 1280, 240, 3840);
|
||||
const result = await fetchOembed(url, { maxWidth });
|
||||
sendJson(response, 200, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/" && !requestUrl.searchParams.has("url")) {
|
||||
const providers = await loadProviders();
|
||||
send(response, 200, homePage({ providersCount: providers.length }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestUrl.pathname === "/" || requestUrl.pathname === "/graphic" || requestUrl.pathname.length > 1) {
|
||||
await handleGraphic(requestUrl, response);
|
||||
return;
|
||||
}
|
||||
|
||||
send(response, 404, errorPage("Not found.", 404));
|
||||
} catch (error) {
|
||||
const status = error.status || 500;
|
||||
const acceptsHtml = request.headers.accept?.includes("text/html");
|
||||
const message = error.name === "AbortError" ? "The oEmbed provider timed out." : error.message;
|
||||
|
||||
if (acceptsHtml) {
|
||||
send(response, status, errorPage(message, status));
|
||||
} else {
|
||||
sendJson(response, status, { error: message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const server = createServer(handleRequest);
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`oembed-graphics listening on http://${host}:${port}`);
|
||||
});
|
||||
90
src/templates.js
Normal file
90
src/templates.js
Normal file
@@ -0,0 +1,90 @@
|
||||
function escapeHtml(value = "") {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function commonHead({ title = "oEmbed Graphics" } = {}) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>`;
|
||||
}
|
||||
|
||||
export function homePage({ providersCount = 0 } = {}) {
|
||||
return `${commonHead()}
|
||||
<body class="app">
|
||||
<main class="shell">
|
||||
<section class="panel">
|
||||
<h1>oEmbed Graphics</h1>
|
||||
<form action="/graphic" method="get">
|
||||
<label for="url">Source URL</label>
|
||||
<div class="row">
|
||||
<input id="url" name="url" type="url" required placeholder="https://www.youtube.com/watch?v=..." autocomplete="off">
|
||||
<button type="submit">Load</button>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<label>Width <input name="width" type="number" value="1920" min="320" max="3840"></label>
|
||||
<label>Height <input name="height" type="number" value="1080" min="240" max="2160"></label>
|
||||
<label>Fit
|
||||
<select name="fit">
|
||||
<option value="contain">contain</option>
|
||||
<option value="cover">cover</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><input name="transparent" value="1" type="checkbox" checked> transparent</label>
|
||||
</div>
|
||||
</form>
|
||||
<p>${providersCount} provider URL patterns loaded. Use <code>/graphic?url=...</code> from CasparCG, OBS Browser Source, or OGraf.</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function graphicPage({
|
||||
targetUrl,
|
||||
embed,
|
||||
width,
|
||||
height,
|
||||
fit,
|
||||
transparent,
|
||||
scale,
|
||||
chroma,
|
||||
}) {
|
||||
const title = embed.title || embed.provider_name || "oEmbed Graphic";
|
||||
const html = embed.html || "";
|
||||
const media = embed.type === "photo" && embed.url
|
||||
? `<img src="${escapeHtml(embed.url)}" alt="${escapeHtml(title)}">`
|
||||
: html;
|
||||
|
||||
return `${commonHead({ title })}
|
||||
<body class="graphic ${transparent ? "transparent" : ""}" style="--stage-width:${width}px; --stage-height:${height}px; --scale:${scale}; --chroma:${escapeHtml(chroma)};">
|
||||
<main class="stage fit-${escapeHtml(fit)}">
|
||||
<section class="embed" data-source="${escapeHtml(targetUrl)}">
|
||||
${media}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
export function errorPage(message, status = 500) {
|
||||
return `${commonHead({ title: `Error ${status}` })}
|
||||
<body class="app">
|
||||
<main class="shell">
|
||||
<section class="panel error">
|
||||
<h1>Error ${status}</h1>
|
||||
<p>${escapeHtml(message)}</p>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
40
test/providers.test.js
Normal file
40
test/providers.test.js
Normal file
@@ -0,0 +1,40 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildOembedUrl } from "../src/oembed.js";
|
||||
import { findProvider, flattenProviders, wildcardToRegExp } from "../src/providers.js";
|
||||
|
||||
test("wildcard provider schemes match target URLs", () => {
|
||||
const regex = wildcardToRegExp("https://*.example.com/watch*");
|
||||
|
||||
assert.equal(regex.test("https://video.example.com/watch?v=abc"), true);
|
||||
assert.equal(regex.test("https://example.org/watch?v=abc"), false);
|
||||
});
|
||||
|
||||
test("flattens providers and finds a matching provider", () => {
|
||||
const providers = flattenProviders([
|
||||
{
|
||||
provider_name: "Example",
|
||||
provider_url: "https://example.com",
|
||||
endpoints: [
|
||||
{
|
||||
schemes: ["https://*.example.com/watch*"],
|
||||
url: "https://example.com/oembed.{format}",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const provider = findProvider("https://video.example.com/watch?id=1", providers);
|
||||
|
||||
assert.equal(provider.providerName, "Example");
|
||||
assert.equal(provider.endpoint, "https://example.com/oembed.json");
|
||||
});
|
||||
|
||||
test("builds oEmbed provider request URLs", () => {
|
||||
const url = buildOembedUrl("https://example.com/oembed", "https://video.example.com/watch?id=1", 1280);
|
||||
|
||||
assert.equal(url.searchParams.get("url"), "https://video.example.com/watch?id=1");
|
||||
assert.equal(url.searchParams.get("format"), "json");
|
||||
assert.equal(url.searchParams.get("maxwidth"), "1280");
|
||||
});
|
||||
Reference in New Issue
Block a user