initial commit: appview fetchVideo implementation
just need to make: - viewer (very simple - fetch record, fetchVideo, display <video> tag) - upload/management (need atcute browser oauth client, write to PDS, etcetc)
This commit is contained in:
commit
2ce74c6391
8 changed files with 439 additions and 0 deletions
38
appview/db.ts
Normal file
38
appview/db.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { Database } from "@db/sqlite";
|
||||||
|
|
||||||
|
const conn = new Database("./data/video.db");
|
||||||
|
conn.exec(`pragma journal_mode = WAL;`);
|
||||||
|
|
||||||
|
conn.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS allowlist (
|
||||||
|
repo TEXT NOT NULL UNIQUE PRIMARY KEY -- did
|
||||||
|
) STRICT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
conn.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS videos (
|
||||||
|
id INTEGER NOT NULL PRIMARY KEY,
|
||||||
|
repo TEXT NOT NULL, -- did
|
||||||
|
cid TEXT NOT NULL, -- blob cid
|
||||||
|
fetched_at INTEGER NOT NULL DEFAULT (unixepoch()), -- datetime
|
||||||
|
filename TEXT NOT NULL
|
||||||
|
) STRICT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
const selectAllowlist = conn.prepare("SELECT 1 FROM allowlist WHERE repo = ?");
|
||||||
|
const selectVideo = conn.prepare(
|
||||||
|
"SELECT filename FROM videos WHERE repo = ? AND cid = ?"
|
||||||
|
);
|
||||||
|
const insertVideo = conn.prepare(
|
||||||
|
"INSERT INTO videos (repo, cid, filename) VALUES (?, ?, ?)"
|
||||||
|
);
|
||||||
|
|
||||||
|
export const db = {
|
||||||
|
db: conn,
|
||||||
|
inAllowlist: (did: string) =>
|
||||||
|
(selectAllowlist.value<[boolean]>(did)?.[0] && true) ?? false,
|
||||||
|
getVideo: (did: string, cid: string) =>
|
||||||
|
selectVideo.value<[string]>(did, cid)?.[0],
|
||||||
|
addVideo: (did: string, cid: string, filename: string) =>
|
||||||
|
insertVideo.run(did, cid, filename),
|
||||||
|
};
|
||||||
122
appview/main.ts
Normal file
122
appview/main.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
import { ensureDir } from "@std/fs";
|
||||||
|
import { serveDir } from "@std/http/file-server";
|
||||||
|
|
||||||
|
import * as z from "@zod/mini";
|
||||||
|
import { db } from "./db.ts";
|
||||||
|
|
||||||
|
import {
|
||||||
|
CompositeDidDocumentResolver,
|
||||||
|
PlcDidDocumentResolver,
|
||||||
|
WebDidDocumentResolver,
|
||||||
|
} from "@atcute/identity-resolver";
|
||||||
|
|
||||||
|
const didResolver = new CompositeDidDocumentResolver({
|
||||||
|
methods: {
|
||||||
|
plc: new PlcDidDocumentResolver(),
|
||||||
|
web: new WebDidDocumentResolver(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchVideo(req: Request): Promise<Response> {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new Response(null, {
|
||||||
|
headers: {
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method !== "POST") {
|
||||||
|
return new Response("Method Not Allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const BodySchema = z.object({
|
||||||
|
repo: z.string().check(z.startsWith("did:")),
|
||||||
|
blob: z.string(),
|
||||||
|
});
|
||||||
|
const body = BodySchema.parse(await req.json());
|
||||||
|
|
||||||
|
const existingVideo = db.getVideo(body.repo, body.blob);
|
||||||
|
if (existingVideo !== undefined) {
|
||||||
|
return new Response(JSON.stringify({ filename: existingVideo }), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!db.inAllowlist(body.repo)) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "repo is not allowlisted on AppView" }),
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = await didResolver.resolve(
|
||||||
|
body.repo as `did:${"plc" | "web"}:${string}`
|
||||||
|
);
|
||||||
|
const pdsBaseURL = doc.service?.find(
|
||||||
|
(it) => it.id === "#atproto_pds"
|
||||||
|
)?.serviceEndpoint;
|
||||||
|
if (!pdsBaseURL || typeof pdsBaseURL !== "string")
|
||||||
|
return new Response(null, { status: 400 });
|
||||||
|
|
||||||
|
let filename: string = crypto.randomUUID();
|
||||||
|
|
||||||
|
{
|
||||||
|
const getBlobURL = new URL("/xrpc/com.atproto.sync.getBlob", pdsBaseURL);
|
||||||
|
getBlobURL.searchParams.set("did", body.repo);
|
||||||
|
getBlobURL.searchParams.set("cid", body.blob);
|
||||||
|
|
||||||
|
const blobResponse = await fetch(getBlobURL, {
|
||||||
|
headers: { "user-agent": "video.cerulea.app" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const contentType = blobResponse.headers.get("content-type");
|
||||||
|
if (contentType === "video/mp4") filename += ".mp4";
|
||||||
|
if (contentType === "video/webm") filename += ".webm";
|
||||||
|
|
||||||
|
await ensureDir("./data/videos");
|
||||||
|
using file = await Deno.open("./data/videos/" + filename, {
|
||||||
|
write: true,
|
||||||
|
createNew: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
await blobResponse.body?.pipeTo(file.writable);
|
||||||
|
|
||||||
|
db.addVideo(body.repo, body.blob, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(JSON.stringify({ filename }), {
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"access-control-allow-origin": "*",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(req: Request, _info): Promise<Response> {
|
||||||
|
const pathname = new URL(req.url).pathname;
|
||||||
|
if (pathname === "/xrpc/blue.cerulea.video.fetchVideo") {
|
||||||
|
return await fetchVideo(req);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname.startsWith("/user-content/")) {
|
||||||
|
return await serveDir(req, {
|
||||||
|
fsRoot: "./data/videos",
|
||||||
|
quiet: true,
|
||||||
|
enableCors: true,
|
||||||
|
urlRoot: "user-content",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return await serveDir(req, { fsRoot: "./web", quiet: true });
|
||||||
|
},
|
||||||
|
} satisfies Deno.ServeDefaultExport;
|
||||||
2
data/.gitignore
vendored
Normal file
2
data/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!/.gitignore
|
||||||
13
deno.json
Normal file
13
deno.json
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
{
|
||||||
|
"tasks": {
|
||||||
|
"appview:start": "deno serve -A --port 4080 ./appview/main.ts"
|
||||||
|
},
|
||||||
|
"imports": {
|
||||||
|
"@atcute/identity-resolver": "npm:@atcute/identity-resolver@^1.1.3",
|
||||||
|
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
|
||||||
|
"@std/fs": "jsr:@std/fs@^1.0.17",
|
||||||
|
"@std/http": "jsr:@std/http@^1.0.17",
|
||||||
|
"@std/path": "jsr:@std/path@^1.1.0",
|
||||||
|
"@zod/mini": "npm:@zod/mini@^4.0.0-beta.20250505T195954"
|
||||||
|
}
|
||||||
|
}
|
||||||
187
deno.lock
Normal file
187
deno.lock
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
{
|
||||||
|
"version": "5",
|
||||||
|
"specifiers": {
|
||||||
|
"jsr:@db/sqlite@0.12": "0.12.0",
|
||||||
|
"jsr:@denosaurs/plug@1": "1.1.0",
|
||||||
|
"jsr:@std/assert@0.217": "0.217.0",
|
||||||
|
"jsr:@std/assert@0.221": "0.221.0",
|
||||||
|
"jsr:@std/cli@^1.0.18": "1.0.18",
|
||||||
|
"jsr:@std/encoding@0.221": "0.221.0",
|
||||||
|
"jsr:@std/encoding@1": "1.0.10",
|
||||||
|
"jsr:@std/encoding@^1.0.10": "1.0.10",
|
||||||
|
"jsr:@std/fmt@0.221": "0.221.0",
|
||||||
|
"jsr:@std/fmt@1": "1.0.8",
|
||||||
|
"jsr:@std/fmt@^1.0.8": "1.0.8",
|
||||||
|
"jsr:@std/fs@0.221": "0.221.0",
|
||||||
|
"jsr:@std/fs@1": "1.0.17",
|
||||||
|
"jsr:@std/fs@^1.0.17": "1.0.17",
|
||||||
|
"jsr:@std/html@^1.0.4": "1.0.4",
|
||||||
|
"jsr:@std/http@^1.0.17": "1.0.17",
|
||||||
|
"jsr:@std/media-types@^1.1.0": "1.1.0",
|
||||||
|
"jsr:@std/net@^1.0.4": "1.0.4",
|
||||||
|
"jsr:@std/path@0.217": "0.217.0",
|
||||||
|
"jsr:@std/path@0.221": "0.221.0",
|
||||||
|
"jsr:@std/path@1": "1.1.0",
|
||||||
|
"jsr:@std/path@^1.0.9": "1.1.0",
|
||||||
|
"jsr:@std/path@^1.1.0": "1.1.0",
|
||||||
|
"jsr:@std/streams@^1.0.9": "1.0.9",
|
||||||
|
"npm:@atcute/identity-resolver@^1.1.3": "1.1.3_@atcute+identity@1.0.2",
|
||||||
|
"npm:@zod/mini@^4.0.0-beta.20250505T195954": "4.0.0-beta.20250505T195954"
|
||||||
|
},
|
||||||
|
"jsr": {
|
||||||
|
"@db/sqlite@0.12.0": {
|
||||||
|
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@denosaurs/plug",
|
||||||
|
"jsr:@std/path@0.217"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@denosaurs/plug@1.0.6": {
|
||||||
|
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/encoding@0.221",
|
||||||
|
"jsr:@std/fmt@0.221",
|
||||||
|
"jsr:@std/fs@0.221",
|
||||||
|
"jsr:@std/path@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@denosaurs/plug@1.1.0": {
|
||||||
|
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/encoding@1",
|
||||||
|
"jsr:@std/fmt@1",
|
||||||
|
"jsr:@std/fs@1",
|
||||||
|
"jsr:@std/path@1"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/assert@0.217.0": {
|
||||||
|
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
|
||||||
|
},
|
||||||
|
"@std/assert@0.221.0": {
|
||||||
|
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
|
||||||
|
},
|
||||||
|
"@std/cli@1.0.18": {
|
||||||
|
"integrity": "33846eab6a7cac52156cc105a798451df06965693606e4668adfe0436a155fd7"
|
||||||
|
},
|
||||||
|
"@std/encoding@0.221.0": {
|
||||||
|
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
|
||||||
|
},
|
||||||
|
"@std/encoding@1.0.10": {
|
||||||
|
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
|
||||||
|
},
|
||||||
|
"@std/fmt@0.221.0": {
|
||||||
|
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
|
||||||
|
},
|
||||||
|
"@std/fmt@1.0.8": {
|
||||||
|
"integrity": "71e1fc498787e4434d213647a6e43e794af4fd393ef8f52062246e06f7e372b7"
|
||||||
|
},
|
||||||
|
"@std/fs@0.221.0": {
|
||||||
|
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.221",
|
||||||
|
"jsr:@std/path@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/fs@1.0.17": {
|
||||||
|
"integrity": "1c00c632677c1158988ef7a004cb16137f870aafdb8163b9dce86ec652f3952b",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/path@^1.0.9"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/html@1.0.4": {
|
||||||
|
"integrity": "eff3497c08164e6ada49b7f81a28b5108087033823153d065e3f89467dd3d50e"
|
||||||
|
},
|
||||||
|
"@std/http@1.0.17": {
|
||||||
|
"integrity": "98aec8ab4080d95c21f731e3008f69c29c5012d12f1b4e553f85935db601569f",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/cli",
|
||||||
|
"jsr:@std/encoding@^1.0.10",
|
||||||
|
"jsr:@std/fmt@^1.0.8",
|
||||||
|
"jsr:@std/html",
|
||||||
|
"jsr:@std/media-types",
|
||||||
|
"jsr:@std/net",
|
||||||
|
"jsr:@std/path@^1.1.0",
|
||||||
|
"jsr:@std/streams"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/media-types@1.1.0": {
|
||||||
|
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
|
||||||
|
},
|
||||||
|
"@std/net@1.0.4": {
|
||||||
|
"integrity": "2f403b455ebbccf83d8a027d29c5a9e3a2452fea39bb2da7f2c04af09c8bc852"
|
||||||
|
},
|
||||||
|
"@std/path@0.217.0": {
|
||||||
|
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.217"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/path@0.221.0": {
|
||||||
|
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@std/assert@0.221"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@std/path@1.1.0": {
|
||||||
|
"integrity": "ddc94f8e3c275627281cbc23341df6b8bcc874d70374f75fec2533521e3d6886"
|
||||||
|
},
|
||||||
|
"@std/streams@1.0.9": {
|
||||||
|
"integrity": "a9d26b1988cdd7aa7b1f4b51e1c36c1557f3f252880fa6cc5b9f37078b1a5035"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"npm": {
|
||||||
|
"@atcute/identity-resolver@1.1.3_@atcute+identity@1.0.2": {
|
||||||
|
"integrity": "sha512-KZgGgg99CWaV7Df3+h3X/WMrDzTPQVfsaoIVbTNLx2B56BvCL2EmaxPSVw/7BFUJMZHlVU4rtoEB4lyvNyMswA==",
|
||||||
|
"dependencies": [
|
||||||
|
"@atcute/identity",
|
||||||
|
"@atcute/lexicons",
|
||||||
|
"@atcute/util-fetch",
|
||||||
|
"@badrap/valita"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@atcute/identity@1.0.2": {
|
||||||
|
"integrity": "sha512-SrDPHuEarEHj9bx7NfYn7DYG6kIgJIMRU581iOCIaVaiZ1WhE9D8QxTxeYG/rbGNSa85E891ECp1sQcKiBN0kg==",
|
||||||
|
"dependencies": [
|
||||||
|
"@atcute/lexicons",
|
||||||
|
"@badrap/valita"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@atcute/lexicons@1.0.4": {
|
||||||
|
"integrity": "sha512-VyGJuGKAIeE+71UT9aSMJJdvfxfXsdsGMG9acv9rnGT7enVy4TD5XoYQy7TCHZ4YpxXzuHkqjyAqBz95c4WkRg==",
|
||||||
|
"dependencies": [
|
||||||
|
"esm-env"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@atcute/util-fetch@1.0.1": {
|
||||||
|
"integrity": "sha512-Clc0E/5ufyGBVfYBUwWNlHONlZCoblSr4Ho50l1LhmRPGB1Wu/AQ9Sz+rsBg7fdaW/auve8ulmwhRhnX2cGRow==",
|
||||||
|
"dependencies": [
|
||||||
|
"@badrap/valita"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"@badrap/valita@0.4.5": {
|
||||||
|
"integrity": "sha512-4QwGbuhh/JesHRQj79mO/l37PvJj4l/tlAu7+S1n4h47qwaNpZ0WDvIwUGLYUsdi9uQ5UPpiG9wb1Wm3XUFBUQ=="
|
||||||
|
},
|
||||||
|
"@zod/core@0.11.6": {
|
||||||
|
"integrity": "sha512-03Bv82fFSfjDAvMfdHHdGSS6SOJs0iCcJlWJv1kJHRtoTT02hZpyip/2Lk6oo4l4FtjuwTrsEQTwg/LD8I7dJA=="
|
||||||
|
},
|
||||||
|
"@zod/mini@4.0.0-beta.20250505T195954": {
|
||||||
|
"integrity": "sha512-ioybPtU4w4TqwHvJv0gkAiYNaBkZ/BaGHBpK7viCIRSE8BiiZucVZ8vS0YE04Qy1R120nAnFy1d+tD9ByMO0yw==",
|
||||||
|
"dependencies": [
|
||||||
|
"@zod/core"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"esm-env@1.2.2": {
|
||||||
|
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA=="
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workspace": {
|
||||||
|
"dependencies": [
|
||||||
|
"jsr:@db/sqlite@0.12",
|
||||||
|
"jsr:@std/fs@^1.0.17",
|
||||||
|
"jsr:@std/http@^1.0.17",
|
||||||
|
"jsr:@std/path@^1.1.0",
|
||||||
|
"npm:@atcute/identity-resolver@^1.1.3",
|
||||||
|
"npm:@zod/mini@^4.0.0-beta.20250505T195954"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
38
lexicon/fetchVideo.json
Normal file
38
lexicon/fetchVideo.json
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "blue.cerulea.video.fetchVideo",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "procedure",
|
||||||
|
"input": {
|
||||||
|
"encoding": "application/json",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["repo", "blob"],
|
||||||
|
"properties": {
|
||||||
|
"repo": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "did"
|
||||||
|
},
|
||||||
|
"blob": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "cid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"output": {
|
||||||
|
"encoding": "application/json",
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["filename"],
|
||||||
|
"properties": {
|
||||||
|
"filename": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
lexicon/video.json
Normal file
29
lexicon/video.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"lexicon": 1,
|
||||||
|
"id": "blue.cerulea.video.video",
|
||||||
|
"defs": {
|
||||||
|
"main": {
|
||||||
|
"type": "record",
|
||||||
|
"key": "any",
|
||||||
|
"record": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["video"],
|
||||||
|
"properties": {
|
||||||
|
"video": {
|
||||||
|
"type": "blob",
|
||||||
|
"accept": ["video/mp4", "video/webm"],
|
||||||
|
"maxSize": 536870912
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"maxGraphemes": 300,
|
||||||
|
"maxLength": 3000
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
web/index.html
Normal file
10
web/index.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!doctype html>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>video.cerulea.blue</title>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue