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
|
# 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