Compare commits

..

No commits in common. "665b7ea924bd11eabdf30d69eaea0cb424175463" and "6caf2dbcc31326d5ff52a155fe5466b630ff60d3" have entirely different histories.

77 changed files with 127 additions and 9438 deletions

130
.vscode/tasks.json vendored
View File

@ -1,130 +0,0 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "Scrappy deploy",
"type": "shell",
"command": "node utils/deploy.js",
"problemMatcher": [],
"options": {
"cwd": "services/pocketbase"
},
"isBackground": false
},
{
"label": "Run Tailscale",
"type": "shell",
"command": "tailscale funnel 8090",
"problemMatcher": [],
"isBackground": true
},
{
"label": "Run postgres",
"type": "shell",
"command": "docker run -it -p 5439:5432 --rm --name futureporn-postgres -e POSTGRES_PASSWORD=password -e POSTGRES_USER=postgres -e POSTGRES_DB=future_porn postgres:17",
"problemMatcher": [],
"isBackground": true
},
{
"label": "Run pgadmin",
"type": "shell",
"command": "docker run -it -p 5050:5050 --rm --name futureporn-pgadmin -e PGADMIN_LISTEN_PORT=5050 -e PGADMIN_DISABLE_POSTFIX=1 -e PGADMIN_DEFAULT_EMAIL=cj@futureporn.net -e PGADMIN_DEFAULT_PASSWORD=password dpage/pgadmin4",
"problemMatcher": [],
"isBackground": true,
},
{
"label": "Run Docker Compose",
"type": "shell",
"command": "docker compose up",
"problemMatcher": [],
"options": {
"cwd": "services/our"
},
"isBackground": true
},
{
"label": "Run All Dev Terminals",
"dependsOn": [
"Run Tailscale",
"Run Docker Compose",
"Run PNPM Dev"
],
"problemMatcher": []
},
{
"label": "Run PNPM Dev",
"type": "shell",
"command": "pnpm run dev",
"options": {
"cwd": "services/our"
},
"problemMatcher": []
},
{
"label": "Run MinIO",
"type": "shell",
"command": "docker run -it --name clipsterpro-minio --rm -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=user -e MINIO_ROOT_PASSWORD=password quay.io/minio/minio server /data --console-address \":9001\"",
"problemMatcher": [],
"isBackground": true,
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Create MinIO Buckets",
"type": "shell",
"command": "until curl -s http://localhost:9000/minio/health/live; do echo 'Waiting for MinIO...'; sleep 1; done; bunx @dotenvx/dotenvx run -f .env.development.local -- ./packages/scripts/create_minio_buckets.sh",
"problemMatcher": [],
"isBackground": false,
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Run Pocketbase",
"type": "shell",
"command": "npx @dotenvx/dotenvx run -f ./.env.development.local -- pocketbase serve --dev --dir ./pb_data",
"problemMatcher": [],
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}/services/pocketbase"
},
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Run Worker",
"type": "shell",
"command": "npx @dotenvx/dotenvx run -f .env.development.local -- npx tsx --watch ./src/index.ts",
"problemMatcher": [],
"isBackground": true,
"options": {
"cwd": "${workspaceFolder}/services/worker"
},
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Run valkey",
"type": "shell",
"command": "docker run --name futureporn-valkey --rm -p 6379:6379 valkey/valkey",
"isBackground": true,
"problemMatcher": [],
"options": {
"cwd": "${workspaceFolder}/services/worker"
},
"runOptions": {
"runOn": "folderOpen"
}
},
{
"label": "Create test task via curl",
"type": "shell",
"command": "curl http://localhost:3000/task?title=fmv",
"isBackground": false,
"problemMatcher": [],
}
]
}

View File

@ -1,6 +1,6 @@
{ {
"name": "futureporn", "name": "futureporn",
"version": "3.3.0", "version": "3.1.0",
"private": true, "private": true,
"description": "Dedication to the preservation of lewdtuber history", "description": "Dedication to the preservation of lewdtuber history",
"license": "Unlicense", "license": "Unlicense",

View File

@ -10,10 +10,22 @@
* @see https://github.com/pocketbase/pocketbase/discussions/5995 * @see https://github.com/pocketbase/pocketbase/discussions/5995
* *
*/ */
onFileDownloadRequest((event) => { onFileDownloadRequest((e) => {
// console.log('event', JSON.stringify(event))
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// console.log('onFileDownloadRequest hook has been triggered ~~~');
// e.next()
// e.app // e.app
// e.collection // e.collection
// e.record // e.record
@ -24,89 +36,72 @@ onFileDownloadRequest((event) => {
const securityKey = process.env?.BUNNY_TOKEN_KEY; const securityKey = process.env?.BUNNY_TOKEN_KEY;
const baseUrl = process.env?.BUNNY_ZONE_URL; const baseUrl = process.env?.BUNNY_ZONE_URL;
// console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`) console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`)
if (!securityKey) { if (!securityKey) {
console.error('BUNNY_TOKEN_KEY was missing from env'); console.error('BUNNY_TOKEN_KEY was missing from env');
return event.next(); return e.next();
} }
if (!baseUrl) { if (!baseUrl) {
console.error('BUNNY_ZONE_URL was missing from env'); console.error('BUNNY_ZONE_URL was missing from env');
return event.next(); return e.next();
} }
/** /**
* Generate a signed BunnyCDN URL. * Generates a BunnyCDN-style signed URL using directory tokens.
* @param {string} securityKey - Your BunnyCDN security token *
* @param {string} baseUrl - The base URL (protocol + host) * We sign URLs to make hotlinking difficult
* @param {string} path - Path to the file (starting with /) * @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
* @param {string} rawQuery - Raw query string, e.g., "width=500&quality=5" * @see https://github.com/pocketbase/pocketbase/discussions/5983#discussioncomment-11426659 // HMAC in pocketbase
* @param {number} expires - Unix timestamp for expiration * @see https://github.com/pocketbase/pocketbase/discussions/6772 // base64 encode the hex
*/ */
function signUrlCool(securityKey, baseUrl, path, rawQuery = "", expires) { function signUrl(securityKey, baseUrl, path, expires) {
if (!path.startsWith('/')) path = '/' + path; if (!path.startsWith('/')) path = '/' + path;
if (baseUrl.endsWith('/')) throw new Error(`baseUrl must not end with a slash. got baseUrl=${baseUrl}`); if (baseUrl.endsWith('/')) throw new Error(`baseUrl must not end with a slash. got baseUrl=${baseUrl}`);
// Build parameter string (sort keys alphabetically)
let parameterData = "";
if (rawQuery) {
const params = rawQuery
.split("&")
.map(p => p.split("="))
.filter(([key]) => key && key !== "token" && key !== "expires")
.sort(([a], [b]) => a.localeCompare(b));
if (params.length) { const hashableBase = securityKey + path + expires;
parameterData = params.map(([k, v]) => `${k}=${v}`).join("&");
}
}
// Build hashable base // Generate and encode the token
const hashableBase = securityKey + path + expires + parameterData;
// console.log(`hashableBase`, hashableBase)
// Compute token using your $security.sha256 workflow
const tokenH = $security.sha256(hashableBase); const tokenH = $security.sha256(hashableBase);
const token = Buffer.from(tokenH, "hex") const token = Buffer.from(tokenH, "hex")
.toString("base64") .toString("base64")
.replace(/\n/g, "") .replace(/\n/g, "") // Remove newlines
.replace(/\+/g, "-") .replace(/\+/g, "-") // Replace + with -
.replace(/\//g, "_") .replace(/\//g, "_") // Replace / with _
.replace(/=/g, ""); .replace(/=/g, ""); // Remove =
// Build final signed URL
let tokenUrl = baseUrl + path + "?token=" + token;
if (parameterData) tokenUrl += "&" + parameterData;
tokenUrl += "&expires=" + expires;
return tokenUrl; // Generate the URL
const signedUrl = baseUrl + path + '?token=' + token + '&expires=' + expires;
return signedUrl;
} }
console.log(`record: ${JSON.stringify(e.record)}`)
console.log(`collection: ${JSON.stringify(e.collection)}`)
const rawQuery = event.requestEvent.request.url.rawQuery; console.log(`app: ${JSON.stringify(e.app)}`)
console.log(`fileField: ${JSON.stringify(e.fileField)}`)
// console.log(`record: ${JSON.stringify(event.record)}`) console.log(`servedPath: ${JSON.stringify(e.servedPath)}`)
// // console.log(`collection: ${JSON.stringify(event.collection)}`) console.log(`servedName: ${JSON.stringify(e.servedName)}`)
// console.log(`app: ${JSON.stringify(event.app)}`)
// console.log(`fileField: ${JSON.stringify(event.fileField)}`)
// console.log(`servedPath: ${JSON.stringify(event.servedPath)}`)
// console.log(`servedName: ${JSON.stringify(event.servedName)}`)
// Our job here is to take the servedPath, and sign it using bunnycdn method // Our job here is to take the servedPath, and sign it using bunnycdn method
// Then serve a 302 redirect instead of serving the file proxied thru PB // Then serve a 302 redirect instead of serving the file proxied thru PB
const path = event.servedPath; const path = e.servedPath;
const expires = Math.round(Date.now() / 1000) + 3600; const expires = Math.round(Date.now() / 1000) + 3600;
const signedUrl = signUrlCool(securityKey, baseUrl, path, rawQuery, expires); const signedUrl = signUrl(securityKey, baseUrl, path, expires);
// console.log(`rawQUery`, rawQuery, 'path', path); console.log(`signedUrl=${signedUrl}`);
// console.log(`signedUrl=${signedUrl}`);
// This redirect is a tricky thing. We do this to avoid proxying file requests via our pocketbase origin server. // This redirect is a tricky thing. We do this to avoid proxying file requests via our pocketbase origin server.
// The idea is to reduce load. // The idea is to reduce load.
// HOWEVER, this redirect slows down image loading because it now takes 2 requests per image. // HOWEVER, this redirect slows down image loading because it now takes 2 requests per image.
event.redirect(302, signedUrl); e.redirect(302, signedUrl);
event.next() e.next()
}) })

View File

@ -5,7 +5,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title> <title>
<%= meta('title') %> <%=meta('title') || '~~~~~' %>
</title> </title>
<meta name="description" content="<%=meta('description') || 'aaaaa'%>" /> <meta name="description" content="<%=meta('description') || 'aaaaa'%>" />
<meta property="og:title" content="<%=meta('title') || 'Futureporn.net'%>" /> <meta property="og:title" content="<%=meta('title') || 'Futureporn.net'%>" />
@ -71,11 +71,8 @@
<footer class="footer mt-5"> <footer class="footer mt-5">
<div class="content has-text-centered"> <div class="content has-text-centered">
<p> <p>
<strong>Futureporn <%= meta('version') %></strong> made with love by <a href="https://t.co/I8p0oH0AAB">@CJ_Clippy</a>. <strong>Futureporn <%= data.version %></strong> made with love by <a href="https://t.co/I8p0oH0AAB">@CJ_Clippy</a>.
</p> </p>
<div class="notification is-warning">A lot of things are broken right now and may not work. Please check back again soon.</div>
</div> </div>
</footer> </footer>

View File

@ -1,18 +1,31 @@
/** @type {import('pocketpages').PageDataLoaderFunc} */ /** @type {import('pocketpages').PageDataLoaderFunc} */
/**
*
* This middleware handles setting data.user for auth purposes
*/
module.exports = function ({ meta, redirect, request, auth }) { module.exports = function ({ meta, redirect, request, auth }) {
meta('title', 'Futureporn.net')
meta('description', 'Dedication to the preservation of Lewdtuber history')
meta('image', 'https://example.com/about-preview.jpg')
const cookies = request.cookies()
// console.log('cookies as follows')
// console.log(JSON.stringify(cookies))
// console.log('auth as follows')
// console.log(auth)
let user; let user;
if (auth) { if (auth) {
console.log('request.auth is present id:', auth.get('id'))
user = $app.findFirstRecordByData('users', 'id', auth.id); user = $app.findFirstRecordByData('users', 'id', auth.id);
} }
return { user } return { user, version: require(`../../../package.json`).version }
} }
// module.exports = (api, next) => {
// const { auth, redirect } = api
// if (!auth) {
// return redirect('/auth/login', {
// message: 'You must be logged in to access this page',
// })
// }
// next()
// }

View File

@ -23,7 +23,7 @@
<div class="mt-5"> <div class="mt-5">
<h3 class="title is-3">Account Settings</h3> <h3 class="title is-3">Account Settings</h3>
<label class="checkbox" data-signals='{"publicUsername": <%= auth.get("publicUsername") ? "true" : "false" %>}'> <label class="checkbox">
<input class="checkbox" type="checkbox" name="publicUsername" data-bind="publicUsername" data-on-input__debounce.300ms.leading="@patch('/api/user/settings')"> <input class="checkbox" type="checkbox" name="publicUsername" data-bind="publicUsername" data-on-input__debounce.300ms.leading="@patch('/api/user/settings')">
Show <%= auth.get('name') %> on the <a href="/patrons">patrons page</a> Show <%= auth.get('name') %> on the <a href="/patrons">patrons page</a>
</label> </label>

View File

@ -1,14 +0,0 @@
<%#
feed source files are in pb_hooks/pages/feed
they are there so the +layout.ejs doesn't apply
%>
<h2 class="title is-2">Content Feeds</h2>
<h3 class="title is-3">VODS</h3>
<div class="content">
<ul class="1">
<li><a href="/vods/feed.json">/vods/feed.json</a></li>
<li><a href="/vods/feed.xml">/vods/feed.xml</a></li>
<li><a href="/vods/rss.xml">/vods/rss.xml</a></li>
</ul>
</div>

View File

@ -1,3 +1,9 @@
<% if (!data?.user?.get('patron')) { %>
<div class="notification is-info">
Thank you for your support on <a href="https://patreon.com/cj_clippy">Patreon!</a>
</div>
<% } %>
<ul class="mt-5"> <ul class="mt-5">
<li class="mb-2"> <li class="mb-2">
<a class="button" href="/vt"> <a class="button" href="/vt">

View File

@ -8,7 +8,7 @@ module.exports = function (api) {
// Find all users who have both a publicUsername and are patrons // Find all users who have both a publicUsername and are patrons
const patronsRaw = $app.findRecordsByFilter( const patronsRaw = $app.findRecordsByFilter(
'users', 'users',
'publicUsername = true && patron = true', 'publicUsername != "" && patron = true',
'-created', // sort (optional) '-created', // sort (optional)
50, // limit 50, // limit
0, // offset 0, // offset

View File

@ -63,13 +63,9 @@
<p><b>IPFS CID:</b> <%= data.vod?.get('ipfsCid') %></p> <p><b>IPFS CID:</b> <%= data.vod?.get('ipfsCid') %></p>
<% } %> <% } %>
<% if (data.vod?.get('magnetLink')) { %>
<p><b>Magnet Link:</b> <%= data.vod?.get('magnetLink') %></p>
<% } %>
<% if (data.vod?.get('notes')) { %> <% if (data.vod?.get('notes')) { %>
<p><b>Notes:</b></p> <p><b>Notes:</b></p>
<div class="p-2 level"><%- data.vod?.get('notes') %></div> <pre class="p-2"><%= data.vod?.get('notes') %></pre>
<% } %> <% } %>
<% if (data.vod?.get('thumbnail')) { %> <% if (data.vod?.get('thumbnail')) { %>

View File

@ -8,6 +8,7 @@ module.exports = {
'pocketpages-plugin-realtime', 'pocketpages-plugin-realtime',
'pocketpages-plugin-auth', 'pocketpages-plugin-auth',
'pocketpages-plugin-js-sdk', 'pocketpages-plugin-js-sdk',
'pocketpages-plugin-micro-dash' 'pocketpages-plugin-micro-dash',
'../../../src/plugins/patreon'
], ],
} }

View File

@ -1,15 +0,0 @@
/** @type {import('pocketpages').PageDataLoaderFunc} */
/**
*
* This middleware populates pocketpages meta fields
*/
module.exports = function ({ meta, redirect, request, auth }) {
meta('title', 'Futureporn.net')
meta('description', 'Dedication to the preservation of Lewdtuber history')
meta('image', 'https://futureporn.net/assets/logo.png')
meta('version', require(`../../package.json`)?.version)
}

View File

@ -29,7 +29,7 @@
<td style="width: 160px;"> <td style="width: 160px;">
<% if (vod.thumbnail) { %> <% if (vod.thumbnail) { %>
<figure class="image is-3by2"> <figure class="image is-3by2">
<img src="/api/files/<%= vod.collectionId %>/<%= vod.id %>/<%= vod.thumbnail %>?quality=5&width=12" alt="Thumbnail" style="width: 120px; border-radius: 8px;"> <img src="/api/files/<%= vod.collectionId %>/<%= vod.id %>/<%= vod.thumbnail %>" alt="Thumbnail" style="width: 120px; border-radius: 8px;">
</figure> </figure>
<% } else { %> <% } else { %>
<span>No thumbnail</span> <span>No thumbnail</span>

View File

@ -22,11 +22,10 @@
return response.html(401, "Auth required") return response.html(401, "Auth required")
} }
console.log('signals as follows')
const signals = datastar.readSignals(request, {}) const signals = datastar.readSignals(request, {})
console.log('signals as followssssssss', JSON.stringify(signals));
user.set('publicUsername', signals.publicUsername); user.set('publicUsername', signals.publicUsername);
$app.save(user);
// Determine the publicUsername status // Determine the publicUsername status
const publicStatus = user.get('publicUsername') ? const publicStatus = user.get('publicUsername') ?

View File

@ -4,8 +4,9 @@
module.exports = function (api) { module.exports = function (api) {
const { params, response } = api; const { params, response } = api;
try { try {
const vods = $app.findRecordsByFilter('vods', null, '-streamDate', 25); const vods = $app.findRecordsByFilter('vods', null, '-streamDate');
$app.expandRecords(vods, ["vtubers"], null); $app.expandRecords(vods, ["vtubers"], null);
// vods.expandedAll("vtubers");
return { vods }; return { vods };
} catch (e) { } catch (e) {

View File

@ -12,24 +12,18 @@ const feed = {
home_page_url: "https://futureporn.net", home_page_url: "https://futureporn.net",
feed_url: "https://futureporn.net/vods/feed.json", feed_url: "https://futureporn.net/vods/feed.json",
description: meta('description'), description: meta('description'),
icon: "https://futureporn.net/assets/logo.png", icon: "https://futureporn.net/images/futureporn-icon.png",
author: { author: {
name: "CJ_Clippy", name: "CJ_Clippy",
url: "https://futureporn.net" url: "https://futureporn.net"
}, },
items: data.vods.map(vod => ({ items: data.vods.map(vod => ({
content_html: `VOD ${vod.get('id')} featuring ${vod.get('expand').vtubers.map((vt) => vt.get('displayName')).join(', ')} streamed on ${vod.get('streamDate')}`, content_html: "",
notes: vod.get('notes'),
url: `https://futureporn.net/vods/${vod.id}`, url: `https://futureporn.net/vods/${vod.id}`,
title: vod.title, title: vod.title,
announceUrl: vod.get('announceUrl'), summary: vod.notes || vod.title,
id: vod.get('id'), image: vod.thumbnail,
ipfsCid: vod.get('ipfsCid'), date_modified: vod.updated
magnetLink: vod.get('magnetLink'),
image: vod.get('thumbnail'),
date_modified: vod.get('updated'),
streamDate: vod.get('streamDate'),
vtubers: vod.get('expand').vtubers.map((vt) => vt.get('displayName')).join(', ')
})) }))
}; };
%><%- JSON.stringify(feed, null, 2) %> %><%- JSON.stringify(feed, null, 2) %>

View File

@ -1,29 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate((app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// add field
collection.fields.addAt(16, new Field({
"autogeneratePattern": "",
"hidden": false,
"id": "text1855839614",
"max": 0,
"min": 0,
"name": "magnetLink",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
}))
return app.save(collection)
}, (app) => {
const collection = app.findCollectionByNameOrId("pbc_144770472")
// remove field
collection.fields.removeById("text1855839614")
return app.save(collection)
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 625 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 928 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 835 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 550 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 540 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 248 B

View File

@ -1,5 +1,3 @@
// 2025-11-07-import-thumbnails.js
import PocketBase from 'pocketbase'; import PocketBase from 'pocketbase';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { basename, join } from 'node:path'; import { basename, join } from 'node:path';

View File

@ -1,86 +0,0 @@
import { basename } from 'node:path';
import PocketBase from 'pocketbase';
import { env } from 'node:process';
import spawn from 'nano-spawn';
// Config
const PB_URL = env.PB_URL || 'http://127.0.0.1:8090';
const B2_BUCKET_FROM = env.B2_BUCKET_FROM;
const B2_BUCKET_TO = env.B2_BUCKET_TO;
const USERNAME = env.PB_USERNAME;
const PASSWORD = env.PB_PASSWORD;
const vodsCollectionName = 'pbc_144770472';
const pb = new PocketBase(PB_URL);
// retry wrapper for async tasks
async function retryAsync(fn, retries = 6, delayMs = 2000) {
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
console.warn(`Attempt ${attempt}/${retries} failed: ${err.message}`, err);
if (attempt === retries) throw err;
await new Promise(r => setTimeout(r, delayMs * attempt)); // exponential-ish backoff
}
}
}
async function main() {
console.log('Authenticating with PocketBase...');
await retryAsync(() =>
pb.collection('_superusers').authWithPassword(USERNAME, PASSWORD)
);
console.log('Fetching all VODs...');
const vods = await retryAsync(() => pb.collection('vods').getFullList());
console.log(`Found ${vods.length} VODs. Updating sourceVideo fields...`);
for (const vod of vods) {
const oldSource = vod.sourceVideo;
if (!oldSource) continue;
const filename = basename(oldSource);
const newSource = `${vodsCollectionName}/${vod.id}/${filename}`;
if (newSource === oldSource) {
console.log(`Skipping VOD ${vod.id}, already updated.`);
continue;
}
if (!oldSource.includes('content/')) {
console.log(`Skipping VOD ${vod.id}, already copied.`);
continue;
}
const from = `b2://${B2_BUCKET_FROM}/${oldSource.replace('content/', '')}`;
const to = `b2://${B2_BUCKET_TO}/${newSource}`;
console.log(`Copying ${from} -> ${to}`);
try {
await retryAsync(() => spawn('b2', ['file', 'server-side-copy', from, to]));
} catch (err) {
console.error(`Failed to copy for VOD ${vod.id}: ${err.message}`, err);
continue; // skip to next vod
}
console.log(`Updating VOD ${vod.id} record...`);
try {
await retryAsync(() =>
pb.collection('vods').update(vod.id, { sourceVideo: newSource })
);
} catch (err) {
console.error(`Failed to update VOD ${vod.id}: ${err.message}`);
continue;
}
console.log(`✅ Updated VOD ${vod.id}: ${oldSource}${newSource}`);
}
console.log('🎉 All done.');
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});

View File

@ -1,149 +0,0 @@
// 2025-11-07-import-thumbnails.js
import PocketBase from 'pocketbase';
import { readFileSync } from 'node:fs';
import { basename, join } from 'node:path';
import spawn from 'nano-spawn';
import { tmpdir } from 'node:os';
import mime from 'mime';
const pb = new PocketBase(process.env.POCKETBASE_URL || 'http://127.0.0.1:8090');
if (!process.env.POCKETBASE_USERNAME) throw new Error('POCKETBASE_USERNAME missing');
if (!process.env.POCKETBASE_PASSWORD) throw new Error('POCKETBASE_PASSWORD missing');
if (!process.env.B2_BUCKET_FROM) throw new Error('B2_BUCKET_FROM missing');
if (!process.env.V1_DATA_FILE) throw new Error('V1_DATA_FILE missing');
interface ManifestItem {
thumbnail_url: string;
thumbnail_key: string;
tmp_file: string;
vod_id: string;
}
type Manifest = ManifestItem[];
interface Vod {
id: string;
thumbnail: string;
streamDate: string;
}
const vodsCollectionName = 'pbc_144770472';
// pbc_144770472 is the vods collection
// cv6m31vj98gmtsx is a sample vod id
async function main() {
console.log('Authenticating with PocketBase...');
await pb
.collection("_superusers")
.authWithPassword(process.env.POCKETBASE_USERNAME!, process.env.POCKETBASE_PASSWORD!);
// Use vod.streamDate to find the correct entry
// load v1 datafile
const v1VodDataRaw = readFileSync(process.env.V1_DATA_FILE!, { encoding: 'utf-8' });
const v1VodData = JSON.parse(v1VodDataRaw).map((vod: any) => vod.data);
console.log('v1VodData sample', v1VodData[0])
// # BUILD A MANIFEST
let manifest: Manifest = [];
const vods = await pb.collection('vods').getFullList();
for (const vod of vods) {
// console.log('v2VodData sample', vod)
// console.log(`for this vod`, vod)
const v1Vod = v1VodData.find((vod1: Vod) => new Date(vod1.streamDate).getTime() === new Date(vod.streamDate).getTime());
console.log(`v1vod`, v1Vod);
if (!v1Vod) {
console.warn(`failed to find matching v1 data vod for vod ${vod.id} ${vod.streamDate}`);
continue;
}
// skip if there is no thumbnail in the v1 vod
if (!v1Vod.thumbnail) continue;
// get a temporary file path to which we will DL
const tmpFile = join(tmpdir(), basename(v1Vod.thumbnail));
// we will take the thumbnail url, download it, then upload it to the specified vod record.
const entry: ManifestItem = {
thumbnail_url: v1Vod.thumbnail,
thumbnail_key: basename(v1Vod.thumbnail),
tmp_file: tmpFile,
vod_id: vod.id,
};
manifest.push(entry);
}
// sanity check
const invalidVods = manifest.filter((m) => m.thumbnail_url.includes('mp4'))
if (invalidVods.length > 0) {
console.warn('invalid thumbnails found', invalidVods)
// throw new Error('invalid. mp4s found in thumbnails');
}
const validManifest = manifest.filter((m) => !m.thumbnail_url.includes('mp4'));
// console.log('manifest', manifest);
// # ACTUAL WORK
// Download the thumbnail to tmp file
// upload the tmp file to pocketbase
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function retry(fn: any, retries = 6, delayMs = 500) {
let lastError;
for (let attempt = 1; attempt <= retries; attempt++) {
try {
return await fn();
} catch (err) {
lastError = err;
console.warn(`Attempt ${attempt} failed: ${err}. Retrying in ${delayMs}ms...`);
if (attempt < retries) await sleep(delayMs);
}
}
throw lastError;
}
for (const [i, m] of validManifest.entries()) {
var from = "b2://" + process.env.B2_BUCKET_FROM + "/" + m.thumbnail_key;
var to = m.tmp_file;
var vodId = m.vod_id;
console.log("processing thumbnail " + i + ". " + from + " -> " + to + " (vod_id=" + m.vod_id + ")");
// Retry the download
await retry(function () {
return spawn('b2', ['file', 'download', from, to]);
});
// Retry the PocketBase upload
await retry(async function () {
var fileData = readFileSync(m.tmp_file);
var mimetype = mime.getType(m.tmp_file) || undefined;
var file = new File([fileData], basename(m.tmp_file), { type: mimetype });
var form = new FormData();
form.set("thumbnail", file);
return pb.collection('vods').update(vodId, form);
});
}
console.log("All done.");
}
main()

View File

@ -1,29 +1,43 @@
#!/usr/bin/env node #!/usr/bin/env node
import spawn from 'nano-spawn'; import PocketBase from 'pocketbase';
import { spawn } from 'node:child_process';
import util from 'node:util';
const spawnAsync = util.promisify(spawn);
async function main() { if (!process.env.POCKETBASE_PASSWORD) throw new Error('POCKETBASE_PASSWORD missing in env');
if (!process.env.POCKETBASE_USERNAME) throw new Error('POCKETBASE_USERNAME missing in env');
if (!process.env.APPURL) throw new Error('APPURL missing in env');
const pb = new PocketBase('http://localhost:8090');
// upload the site await pb
await spawn('rsync', [ .collection("_superusers")
.authWithPassword(process.env.POCKETBASE_USERNAME, process.env.POCKETBASE_PASSWORD);
// change to the production APPURL
await pb.settings.update({
meta: {
appName: 'Futureporn',
appUrl: 'https://futureporn.net',
},
});
// upload the site
spawnAsync('rsync', [
'-avz', '-avz',
'--exclude=pb_data',
'--exclude=*.local',
'-e', '-e',
'ssh', 'ssh',
'.', '.',
'root@fp:/home/pb/pb' 'root@fp:/home/pb/pb'
], { ]);
stdio: 'inherit'
});
// fix ownership // @see https://pocketbase.io/docs/api-settings/#update-settings
await spawn('ssh', ['fp', 'chown', '-R', 'pb:pb', '/home/pb/pb']);
// restart pocketbase // put it back to dev app url
await spawn('systemctl', ['--host=fp', 'restart', 'pocketbase.service']); await pb.settings.update({
meta: {
} appName: 'Futureporn',
appUrl: process.env.APPURL,
main(); },
});

View File

@ -1,8 +0,0 @@
import { Queue as QueueMQ, Worker, type QueueOptions, type JobsOptions, type Job } from 'bullmq';
export const connection: QueueOptions['connection'] = {
host: '127.0.0.1',
port: 6379,
password: ''
};

View File

@ -1,64 +0,0 @@
const env = (() => {
if (!process.env.POCKETBASE_URL) throw new Error('POCKETBASE_URL missing in env');
if (!process.env.PORT) throw new Error('PORT missing in env');
if (!process.env.POCKETBASE_USERNAME) throw new Error('POCKETBASE_USERNAME missing in env');
if (!process.env.POCKETBASE_PASSWORD) throw new Error('POCKETBASE_PASSWORD missing in env');
if (!process.env.MUX_TOKEN_ID) throw new Error('MUX_TOKEN_ID missing in env');
if (!process.env.MUX_TOKEN_SECRET) throw new Error('MUX_TOKEN_SECRET missing in env');
if (!process.env.MUX_SIGNING_KEY_ID) throw new Error('MUX_SIGNING_KEY_ID missing in env');
if (!process.env.MUX_SIGNING_KEY_PRIVATE_KEY) throw new Error('MUX_SIGNING_KEY_PRIVATE_KEY missing in env');
if (!process.env.PATREON_CREATOR_ACCESS_TOKEN) throw new Error('PATREON_CREATOR_ACCESS_TOKEN missing in env');
if (!process.env.VIBEUI_DIR) throw new Error('VIBEUI_DIR missing in env');
if (!process.env.APP_DIR) throw new Error('APP_DIR missing in env');
if (!process.env.AWS_BUCKET) throw new Error('AWS_BUCKET missing in env');
if (!process.env.AWS_ACCESS_KEY_ID) throw new Error('AWS_ACCESS_KEY_ID missing in env');
if (!process.env.AWS_SECRET_ACCESS_KEY) throw new Error('AWS_SECRET_ACCESS_KEY missing in env');
if (!process.env.AWS_REGION) throw new Error('AWS_REGION missing in env');
if (!process.env.AWS_ENDPOINT) throw new Error('AWS_ENDPOINT missing in env');
if (!process.env.FANSLY_USERNAME) throw new Error('FANSLY_USERNAME missing in env');
if (!process.env.FANSLY_PASSWORD) throw new Error('FANSLY_PASSWORD missing in env');
const {
PORT,
POCKETBASE_URL,
POCKETBASE_USERNAME,
POCKETBASE_PASSWORD,
MUX_TOKEN_ID,
MUX_TOKEN_SECRET,
MUX_SIGNING_KEY_ID,
MUX_SIGNING_KEY_PRIVATE_KEY,
PATREON_CREATOR_ACCESS_TOKEN,
VIBEUI_DIR,
APP_DIR,
AWS_BUCKET,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
AWS_ENDPOINT,
FANSLY_USERNAME,
FANSLY_PASSWORD,
} = process.env
return {
PORT,
POCKETBASE_URL,
POCKETBASE_USERNAME,
POCKETBASE_PASSWORD,
MUX_TOKEN_ID,
MUX_TOKEN_SECRET,
MUX_SIGNING_KEY_ID,
MUX_SIGNING_KEY_PRIVATE_KEY,
PATREON_CREATOR_ACCESS_TOKEN,
VIBEUI_DIR,
APP_DIR,
AWS_BUCKET,
AWS_ACCESS_KEY_ID,
AWS_SECRET_ACCESS_KEY,
AWS_REGION,
AWS_ENDPOINT,
FANSLY_PASSWORD,
FANSLY_USERNAME,
}
})()
export default env;

View File

@ -1,29 +0,0 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

View File

@ -1,34 +0,0 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

View File

@ -1,15 +0,0 @@
# worker
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run index.ts
```
This project was created using `bun init` in bun v1.3.1. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.

View File

@ -1,234 +0,0 @@
{
"lockfileVersion": 1,
"workspaces": {
"": {
"name": "worker",
"dependencies": {
"@bull-board/elysia": "^6.14.0",
"@mux/mux-node": "^12.8.0",
"bullmq": "^5.63.0",
"elysia": "^1.4.13",
"pocketbase-queue": "^0.0.5",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@borewit/text-codec": ["@borewit/text-codec@0.1.1", "", {}, "sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA=="],
"@bull-board/api": ["@bull-board/api@6.14.0", "", { "dependencies": { "redis-info": "^3.1.0" }, "peerDependencies": { "@bull-board/ui": "6.14.0" } }, "sha512-oMDwXwoPn0RsdZ3Y68/bOErZ/qGZE5H97vgE/Pc8Uul/OHajlvajKW4NV+ZGTix82liUfH9CkjYx7PpwvBWhxg=="],
"@bull-board/elysia": ["@bull-board/elysia@6.14.0", "", { "dependencies": { "@bull-board/api": "6.14.0", "@bull-board/ui": "6.14.0", "ejs": "^3.1.10", "mimeV4": "npm:mime@^4.0.7" }, "peerDependencies": { "elysia": "^1.1.0" } }, "sha512-5pHQIwnOjDburYzcrSyZxyl66dp4UayYAgw44Z5N3uJimWmlRYWIWw9nszm4NDqCDdEgs5ASOC78sKc3XTRPsw=="],
"@bull-board/ui": ["@bull-board/ui@6.14.0", "", { "dependencies": { "@bull-board/api": "6.14.0" } }, "sha512-5yqfS9CwWR8DBxpReIbqv/VSPFM/zT4KZ75keyApMiejasRC2joaHqEzYWlMCjkMycbNNCvlQNlTbl+C3dE/dg=="],
"@ioredis/commands": ["@ioredis/commands@1.4.0", "", {}, "sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
"@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
"@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
"@mux/mux-node": ["@mux/mux-node@12.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "jose": "^4.14.4", "node-fetch": "^2.6.7" } }, "sha512-J7Qe1JGlOWle+5huN27hBcAKujx0xgC8d3qQ1s9emyEETR+ubxlRf/9UOlN+GoPtoMPIDa5fWDRf2LDWzsiBOA=="],
"@sinclair/typebox": ["@sinclair/typebox@0.34.41", "", {}, "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g=="],
"@tokenizer/inflate": ["@tokenizer/inflate@0.2.7", "", { "dependencies": { "debug": "^4.4.0", "fflate": "^0.8.2", "token-types": "^6.0.0" } }, "sha512-MADQgmZT1eKjp06jpI2yozxaU9uVs4GzzgSL+uEq7bVcJ9V1ZXQkeGNql1fsSI0gMy1vhvNTNbUqrx+pZfJVmg=="],
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="],
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"bullmq": ["bullmq@5.63.0", "", { "dependencies": { "cron-parser": "^4.9.0", "ioredis": "^5.4.1", "msgpackr": "^1.11.2", "node-abort-controller": "^3.1.1", "semver": "^7.5.4", "tslib": "^2.0.0", "uuid": "^11.1.0" } }, "sha512-HT1iM3Jt4bZeg3Ru/MxrOy2iIItxcl1Pz5Ync1Vrot70jBpVguMxFEiSaDU57BwYwR4iwnObDnzct2lirKkX5A=="],
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"cookie": ["cookie@1.0.2", "", {}, "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA=="],
"cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"elysia": ["elysia@1.4.13", "", { "dependencies": { "cookie": "^1.0.2", "exact-mirror": "0.2.2", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-6QaWQEm7QN1UCo1TPpEjaRJPHUmnM7R29y6LY224frDGk5PrpAnWmdHkoZxkcv+JRWp1j2ROr2IHbxHbG/jRjw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"exact-mirror": ["exact-mirror@0.2.2", "", { "peerDependencies": { "@sinclair/typebox": "^0.34.15" }, "optionalPeers": ["@sinclair/typebox"] }, "sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
"file-type": ["file-type@21.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.2.7", "strtok3": "^10.2.2", "token-types": "^6.0.0", "uint8array-extras": "^1.4.0" } }, "sha512-ek5xNX2YBYlXhiUXui3D/BXa3LdqPmoLJ7rqEx2bKJ7EAUEfmXgW0Das7Dc6Nr9MvqaOnIqiPV0mZk/r/UpNAg=="],
"filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="],
"form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
"form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="],
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ioredis": ["ioredis@5.8.2", "", { "dependencies": { "@ioredis/commands": "1.4.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q=="],
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
"jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
"lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"mimeV4": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="],
"minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
"msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
"node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
"openapi-types": ["openapi-types@12.1.3", "", {}, "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"pocketbase": ["pocketbase@0.21.5", "", {}, "sha512-bnI/uinnQps+ElSlzxkc4yvwuSFfKcoszDtXH/4QT2FhGq2mJVUvDlxn+rjRXVntUjPfmMG5LEPZ1eGqV6ssog=="],
"pocketbase-queue": ["pocketbase-queue@0.0.5", "", { "peerDependencies": { "pocketbase": "^0.21.1" } }, "sha512-rPVog0nlUygGaxi2mbQ0cKjk0GcbU7WELHFtyUO5NFLdFnkanMLrK0yC3hUl98YUODZm4lRXx08AwRCgIxSlKQ=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-info": ["redis-info@3.1.0", "", { "dependencies": { "lodash": "^4.17.11" } }, "sha512-ER4L9Sh/vm63DkIE0bkSjxluQlioBiBgf5w1UuldaW/3vPcecdljVDisZhmnCMvsxHNiARTTDDHGg9cGwTfrKg=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"strtok3": ["strtok3@10.3.4", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg=="],
"token-types": ["token-types@6.1.1", "", { "dependencies": { "@borewit/text-codec": "^0.1.0", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
"web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"@types/node-fetch/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
"bun-types/@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
"@types/node-fetch/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"bun-types/@types/node/undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

View File

@ -1,3 +0,0 @@
#!/bin/bash
/home/cj/.nvm/versions/node/v22.18.0/bin/node --import tsx ./src/index.ts

View File

@ -1,24 +0,0 @@
```shell
bun run --watch index.ts
1 | class ClientResponseError extends Error{constructor(e){super("ClientResponseError"),this.url="",this.status=0,this.response={},this.isAbort=!1,this.originalError=null,Object.setPrototypeOf(this,ClientResponseError.prototype),null!==e&&"object"==typeof e&&(this.url="string"==typeof e.url?e.url:"",this.status="number"==typeof e.status?e.status:0,this.isAbort=!!e.isAbort,this.originalError=e.originalError,null!==e.response&&"object"==typeof e.response?this.response=e.response:null!==e.data&&"object"==typeof e.data?this.response=e.data:this.response={}),this.originalError||e instanceof ClientResponseError||(this.originalError=e),"undefined"!=typeof DOMException&&e instanceof DOMException&&(this.isAbort=!0),this.name="ClientResponseError "+this.status,this.message=this.response?.message,this.message||(this.isAbort?this.message="The request was autocancelled. You can find more info in https://github.com/pocketbase/js-sdk#auto-cancellation.":this.originalError?.cause?.message?.includes("ECONNREFUSED ::1")?this.messa | ... truncated
ClientResponseError 404: The requested resource wasn't found.
url: "https://lego.tailb18385.ts.net/api/admins/auth-with-password",
status: 404,
response: {
data: [Object ...],
message: "The requested resource wasn't found.",
status: 404,
},
isAbort: false,
originalError: {
url: "https://lego.tailb18385.ts.net/api/admins/auth-with-password",
status: 404,
data: [Object ...],
},
at <anonymous> (/home/cj/Documents/futureporn-monorepo/services/worker/node_modules/pocketbase/dist/pocketbase.es.mjs:1:32687)
source /home/cj/Documents/futureporn-monorepo/venv/bin/activate
^[
```

View File

@ -1,199 +0,0 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ElysiaAdapter } from '@bull-board/elysia';
import { Queue as QueueMQ, Worker } from 'bullmq';
import Elysia from 'elysia';
import { version } from './package.json';
import PocketBase from 'pocketbase';
import env from './env.ts';
import Mux from '@mux/mux-node';
const { subtle } = globalThis.crypto;
async function createToken(playbackId: string) {
// Set some base options we can use for a few different signing types
// Type can be either video, thumbnail, gif, or storyboard
let baseOptions = {
keyId: env.MUX_SIGNING_KEY_ID, // Enter your signing key id here
keySecret: env.MUX_SIGNING_KEY_PRIVATE_KEY, // Enter your base64 encoded private key here
expiration: '7d', // E.g 60, "2 days", "10h", "7d", numeric value interpreted as seconds
};
const token = await mux.jwt.signPlaybackId(playbackId, { ...baseOptions, type: 'video' });
console.log('video token', token);
// Now the signed playback url should look like this:
// https://stream.mux.com/${playbackId}.m3u8?token=${token}
// // If you wanted to pass in params for something like a gif, use the
// // params key in the options object
// const gifToken = await mux.jwt.signPlaybackId(playbackId, {
// ...baseOptions,
// type: 'gif',
// params: { time: '10' },
// });
// console.log('gif token', gifToken);
// // Then, use this token in a URL like this:
// // https://image.mux.com/${playbackId}/animated.gif?token=${gifToken}
// // A final example, if you wanted to sign a thumbnail url with a playback restriction
// const thumbnailToken = await mux.jwt.signPlaybackId(playbackId, {
// ...baseOptions,
// type: 'thumbnail',
// params: { playback_restriction_id: YOUR_PLAYBACK_RESTRICTION_ID },
// });
// console.log('thumbnail token', thumbnailToken);
// When used in a URL, it should look like this:
// https://image.mux.com/${playbackId}/thumbnail.png?token=${thumbnailToken}
return token
}
// function base64ToArrayBuffer(base64: string) {
// const binary = Buffer.from(base64, 'base64');
// return binary.buffer.slice(binary.byteOffset, binary.byteOffset + binary.byteLength);
// }
// async function importPrivateKey(base64Key: string): Promise<CryptoKey> {
// const keyData = base64ToArrayBuffer(base64Key);
// return await subtle.importKey(
// 'pkcs8', // format for private keys
// keyData,
// { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, // RS256
// false, // not extractable
// ['sign'] // usage
// );
// }
const mux = new Mux({
tokenId: env.MUX_TOKEN_ID,
tokenSecret: env.MUX_TOKEN_SECRET
});
const pb = new PocketBase(env.POCKETBASE_URL);
const sleep = (t: number) => new Promise((resolve) => setTimeout(resolve, t));
const redisOptions = {
port: 6379,
host: 'localhost',
password: '',
};
const createQueueMQ = (name: string) => new QueueMQ(name, { connection: redisOptions });
function setupBullMQProcessor(queueName: string) {
new Worker(
queueName,
async (job) => {
job.log(`the job ${job.data.title} is running`);
const userData = await pb.collection('_superusers').authWithPassword(env.POCKETBASE_USERNAME, env.POCKETBASE_PASSWORD);
job.log(`userData ${JSON.stringify(userData)}`);
// // @todo presign all mux assets
// // 1. for each VOD in pocketbase
// // 2. get the muxPlaybackId
const vods = await pb.collection('vods').getFullList({
sort: '-created',
});
job.log(`there are ${vods.length} vods`);
// job.log(JSON.stringify(vods, null, 2));
// 3. sign the muxPlaybackId
for (let i = 0; i < vods.length; i++) {
const vod = vods[i];
if (!vod) throw new Error(`vod ${i} missing`);
if (vod.muxPlaybackId) {
// const muxPlaybackToken = await mux.jwt.signPlaybackId(vod.muxPlaybackId, {
// expiration: "7d"
// })
const muxPlaybackToken = await createToken(vod.muxPlaybackId);
job.log(`muxPlaybackToken is ${muxPlaybackToken}`);
await pb.collection('vods').update(vod.id, {
muxPlaybackToken
});
}
// Calculate progress as a percentage
const progress = Math.round(((i + 1) / vods.length) * 100);
await job.updateProgress(progress);
}
// const record1 = await pb.collection('vods').getOne('RECORD_ID', {
// expand: 'relField1,relField2.subRelField',
// });
// // list and filter "example" collection records
// const result = await pb.collection('vods').getList(1, 20, {
// filter: 'status = true && created > "2022-08-01 10:00:00"'
// });
// // 4. update the VOD
// const record = await pb.collection('demo').update('YOUR_RECORD_ID', {
// title: 'Lorem ipsum',
// });
// for (let i = 0; i <= 100; i++) {
// await sleep(Math.random());
// await job.updateProgress(i);
// await job.log(`Processing job at interval ${i}`);
// if (Math.random() * 200 < 1) throw new Error(`Random error ${i}`);
// }
return { recordCount: 'ninja' };
},
{ connection: redisOptions }
);
}
const muxBullMq = createQueueMQ('PresignMux');
setupBullMQProcessor(muxBullMq.name);
const serverAdapter = new ElysiaAdapter('/ui');
createBullBoard({
queues: [new BullMQAdapter(muxBullMq)],
serverAdapter,
options: {
// This configuration fixes a build error on Bun caused by eval (https://github.com/oven-sh/bun/issues/5809#issuecomment-2065310008)
uiBasePath: 'node_modules/@bull-board/ui',
},
});
const app = new Elysia()
.onError(({ error, code, request }) => {
console.error(error, code, request.method, request.url);
if (code === 'NOT_FOUND') return 'NOT_FOUND';
})
.use(serverAdapter.registerPlugin())
.get('/', async () => {
return `futureporn worker version ${version}`
})
.get('/task', async ({ query }) => {
await muxBullMq.add('Add', { title: query.title });
return { ok: true };
});
app.listen(3000, ({ port, url }) => {
/* eslint-disable no-console */
console.log(`Running on ${url.hostname}:${port}...`);
console.log(`For the UI of instance1, open http://localhost:${port}/ui`);
console.log('Make sure Redis is running on port 6379 by default');
console.log('To populate the queue, run:');
console.log(` curl http://localhost:${port}/task?title=Example`);
/* eslint-enable no-console */
});

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
{
"name": "worker",
"module": "index.ts",
"type": "module",
"private": true,
"peerDependencies": {
"typescript": "^5"
},
"version": "0.0.1",
"dependencies": {
"@bull-board/express": "^6.14.0",
"@mux/mux-node": "^12.8.0",
"@types/express": "^5.0.5",
"@types/js-yaml": "^4.0.9",
"bullmq": "^5.63.0",
"date-fns": "^4.1.0",
"fs-extra": "^11.3.2",
"js-yaml": "^4.1.0",
"nano-spawn": "^2.0.0",
"nanoid": "^5.1.6",
"onnxruntime-web": "^1.23.2",
"pocketbase": "^0.26.3",
"puppeteer": "^24.30.0",
"puppeteer-extra": "^3.3.6",
"puppeteer-extra-plugin-stealth": "^2.11.2",
"sharp": "^0.34.5",
"slugify": "^1.6.6",
"which": "^5.0.0"
},
"devDependencies": {
"tsx": "^4.20.6",
"vitest": "^4.0.8"
},
"scripts": {
"start": "tsx src/index.ts"
}
}

View File

@ -1,79 +0,0 @@
import { createBullBoard } from '@bull-board/api';
import { BullMQAdapter } from '@bull-board/api/bullMQAdapter';
import { ExpressAdapter } from '@bull-board/express';
import { type JobsOptions } from 'bullmq';
import express, { type Request, type Response } from 'express';
import { generalQueue } from './queues/generalQueue.ts';
import { gpuQueue } from './queues/gpuQueue.ts';
import { highPriorityQueue } from './queues/highPriorityQueue.ts';
import env from '../.config/env.ts';
const run = async () => {
const app = express();
const serverAdapter = new ExpressAdapter();
serverAdapter.setBasePath('/ui');
createBullBoard({
queues: [
new BullMQAdapter(highPriorityQueue),
new BullMQAdapter(generalQueue),
new BullMQAdapter(gpuQueue),
],
serverAdapter,
});
console.log('importing workers');
if (process.env.NODE_ENV === 'development') {
await import('./workers/highPriorityWorker.ts');
await import('./workers/generalWorker.ts');
await import('./workers/gpuWorker.ts');
} else {
// @todo I separated these so that they can be ran on multiple machines
await import('./workers/highPriorityWorker.ts');
await import('./workers/generalWorker.ts');
// await import('./workers/gpuWorker.ts'); // @todo implement
}
app.use('/ui', serverAdapter.getRouter());
app.get('/task', async (req: Request, res: Response) => {
const name = req.query.name as string;
const vodId = req.query.vodId as string;
// console.log('vodId', vodId, 'name', name);
// console.log(JSON.stringify(req.query, null, 2))
const data = { vodId };
switch (name) {
case 'presignMuxAssets':
case 'syncronizePatreon':
case 'analyzeAudio':
await generalQueue.add(name, data);
break;
case 'scheduleVodProcessing':
await gpuQueue.add(name, data);
break;
default:
throw new Error(`Unknown job name: ${name}`);
}
res.json({ ok: true });
});
app.listen(env.PORT, () => {
console.log(`Bull Dashboard running at http://localhost:${env.PORT}/ui`);
console.log('To populate the queue, run ex:');
console.log(` curl http://localhost:${env.PORT}/task?name=presignMuxAssets`);
});
};
run().catch((e) => console.error(e));

View File

@ -1,133 +0,0 @@
/**
* Analyze the video's audio track using ITU-R BS.1770/EBU R128
* to extract Integrated Loudness (LUFS-I), Loudness Range (LRA),
* and True Peak (dBTP). This helps identify which VODs/streamers
* maintain consistent, listener-friendly audio setups.
*
* ## LUFS Reference
* Apple Music -16 LUFS
* YouTube -13 LUFS
* Spotify -14 LUFS
* Tidal -14 LUFS
*/
import spawn from 'nano-spawn';
import { getPocketBaseClient } from "../util/pocketbase";
import { type Job } from "bullmq";
import { join, basename } from 'path';
import { tmpdir } from "os";
import { b2Download } from "../util/b2";
const ffmpegBin = "ffmpeg";
interface Payload {
vodId: string;
}
interface AudioStats {
lufsIntegrated: number; // LUFS-I
lra: number; // Loudness Range
peak: number; // True Peak (expressed as dBTP ceiling, e.g. -0.1)
}
export async function runFFmpeg(args: string[]): Promise<string> {
try {
const result = await spawn(ffmpegBin, args, {
stdout: 'pipe',
stderr: 'pipe',
});
// success → return combined output
return result.output;
} catch (err: any) {
// failure → throw with stderr
throw new Error(err.output || err.message);
}
}
function parseEbur128(output: string): AudioStats {
const lines = output.split("\n").map((l) => l.trim());
let lufsIntegrated = NaN;
let lra = NaN;
let peak = NaN;
let inTruePeak = false;
for (const line of lines) {
// Integrated loudness
const iMatch = line.match(/^I:\s*(-?\d+(\.\d+)?) LUFS/);
if (iMatch) lufsIntegrated = parseFloat(iMatch[1]);
// Loudness range
const lraMatch = line.match(/^LRA:\s*(-?\d+(\.\d+)?)/);
if (lraMatch) lra = parseFloat(lraMatch[1]);
// True peak
if (line === "True peak:") inTruePeak = true;
if (inTruePeak) {
const pMatch = line.match(/Peak:\s*(-?\d+\.?\d*)\s*dBFS/);
if (pMatch) {
peak = parseFloat(pMatch[1]); // no negation
inTruePeak = false;
}
}
}
return { lufsIntegrated, lra, peak };
}
async function analyzeAudio(inputFile: string): Promise<AudioStats> {
const args = [
"-hide_banner",
"-i", inputFile,
"-filter_complex", "ebur128=peak=true:metadata=1:framelog=verbose",
"-f", "null",
"-",
];
const output = await runFFmpeg(args);
return parseEbur128(output);
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function main(job: Job) {
// job.log('TIME TO ANALYZING AUDIO');
const payload = job.data;
assertPayload(payload);
const { vodId } = payload;
const pb = await getPocketBaseClient();
const vod = await pb.collection('vods').getOne(vodId);
if (vod.audioIntegratedLufs && vod.audioLoudnessRange && vod.audioTruePeak) {
job.log(`Skipping analysis — vod ${vodId} already complete.`);
return;
}
if (!vod.sourceVideo) {
throw new Error(`vod ${vodId} is missing a sourceVideo.`);
}
const videoFilePath = join(tmpdir(), basename(vod.sourceVideo));
job.log(`downloading ${vod.sourceVideo} to ${videoFilePath}`);
await b2Download(vod.sourceVideo, videoFilePath);
const results = await analyzeAudio(videoFilePath);
job.log(`results=${JSON.stringify(results)}`);
await vod.collection('vods').update(vodId, {
audioIntegratedLufs: results.lufsIntegrated,
audioLoudnessRange: results.lra,
audioTruePeak: results.peak,
})
}

View File

@ -1,5 +0,0 @@
export async function childTask(data: any) {
console.log(`Processing child job with data:`, data);
await new Promise((r) => setTimeout(r, 500)); // simulate async work
return { success: true, processed: data };
}

View File

@ -1,11 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { cleanExpiredFiles } from "../utils/cache";
import logger from "../utils/logger";
const cleanup: Task = async (_payload, helpers: Helpers) => {
logger.debug(`cleanup begin.`);
let count = await cleanExpiredFiles()
if (count > 0) logger.info(`Deleted ${count} old files.`);
};
export default cleanup;

View File

@ -1,156 +0,0 @@
// Copy futureporn.net s3 asset to future.porn s3 bucket.
// ex: https://futureporn-b2.b-cdn.net/(...) -> https://fp-usc.b-cdn.net/(...)
import logger from "../utils/logger";
import { Task } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { generateS3Path } from '../utils/formatters';
import { getOrDownloadAsset } from "../utils/cache";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { env } from "../config/env";
import type { default as NanoSpawn } from 'nano-spawn';
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
function isV1Url(url: string): Boolean {
return url.includes('futureporn-b2.b-cdn.net');
}
/**
* authorize if needed
*/
async function assertB2Account() {
const spawn = await getNanoSpawn();
// see if we are logged in already
const accountInfoRaw = await spawn('b2', ['account', 'get']);
const accountInfo = JSON.parse(accountInfoRaw.stdout.trim());
if (accountInfo.accountAuthToken) {
logger.debug(`already logged in.`);
return;
}
logger.debug(`authorizing B2 account.`);
const authInfoRaw = await spawn('b2', ['account', 'authorize', env.B2_APPLICATION_KEY_ID, env.B2_APPLICATION_KEY]);
const authInfo = JSON.parse(authInfoRaw.stdout.trim());
logger.debug(`Logged in. ${JSON.stringify(authInfo)}`);
}
/**
* Copy a file from V1 bucket to V2 bucket
* @param v1Url The V1 file URL (b2://futureporn/...)
* @param v2Key Desired key in V2 bucket
* @param contentType Optional override for content type
*/
async function copyFromBucketToBucket(spawn: typeof NanoSpawn, v1Url: string, v2Key: string, contentType?: string) {
// Get file info from V1
logger.info(`v1Url=${v1Url}`);
const infoResult = await spawn('b2', ['file', 'info', v1Url]);
const infoJson = JSON.parse(infoResult.stdout);
const v2Url = `b2://${process.env.S3_BUCKET}/${v2Key}`;
logger.info(`Copying ${v1Url} to ${v2Url}...`);
// Copy file by ID
const copyResult = await spawn('b2', [
'file', 'copy-by-id',
infoJson.fileId,
process.env.S3_BUCKET,
v2Key,
]);
logger.info('Copying complete.');
return v2Url;
}
// example v1 https://futureporn-b2.b-cdn.net/projektmelody-chaturbate-2023-01-01.mp4
// example v2 https://fp-usc.b-cdn.net/projektmelody-chaturbate-2023-01-01.mp4
const copyV1S3ToV2: Task = async (payload: any) => {
logger.info(`copyV1S3ToV2 with vodId=${payload.vodId}`);
const spawn = await getNanoSpawn();
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId,
},
select: {
thumbnail: true,
sourceVideo: true,
streamDate: true,
vtubers: true,
}
})
// find what we need to copy over.
// potentially vod.thumbnail and vod.sourceVideo
const thumbnail = vod.thumbnail;
const sourceVideo = vod.sourceVideo;
if (!thumbnail && !sourceVideo) {
logger.info(`thumbnail and sourceVideo for ${vodId} are missing. nothing to do.`);
return;
}
const slug = vod.vtubers[0].slug
if (!slug) throw new Error(`vtuber ${vod.vtubers[0].id} is missing a slug`);
await assertB2Account();
let v2ThumbnailKey: string | undefined = undefined;
let v2SourceVideoKey: string | undefined = undefined;
if (thumbnail && isV1Url(thumbnail)) {
v2ThumbnailKey = generateS3Path(slug, vod.streamDate, vodId, 'thumbnail.png');
await copyFromBucketToBucket(spawn, thumbnail.replace('https://futureporn-b2.b-cdn.net', 'b2://futureporn'), v2ThumbnailKey, 'application/png');
}
if (sourceVideo && isV1Url(sourceVideo)) {
v2SourceVideoKey = generateS3Path(slug, vod.streamDate, vodId, 'source.mp4');
await copyFromBucketToBucket(spawn, sourceVideo.replace('https://futureporn-b2.b-cdn.net', 'b2://futureporn'), v2SourceVideoKey, 'video/mp4');
}
logger.debug(`updating vod record`);
await prisma.vod.update({
where: { id: vodId },
data: {
...(v2ThumbnailKey ? { thumbnail: v2ThumbnailKey } : {}), // Variable 'v2ThumbnailKey' is used before being assigned.ts(2454)
...(v2SourceVideoKey ? { sourceVideo: v2SourceVideoKey } : {}),
}
});
}
export default copyV1S3ToV2;

View File

@ -1,181 +0,0 @@
import { getOrDownloadAsset } from "../util/cache.ts";
import env from "../../env.ts";
import { getS3Client, uploadFile } from "../util/s3.ts";
import { inference } from "../util/vibeui.ts";
import { buildFunscript } from "../util/funscripts.ts";
import { generateS3Path } from "../util/formatters.ts";
import { type Job } from "bullmq";
import { getPocketBaseClient } from '../util/pocketbase';
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload)
throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string")
throw new Error("invalid payload-- was missing vodId");
}
async function getVod(vodId: string) {
// return prisma.vod.findFirstOrThrow({
// where: { id: vodId },
// include: { vtubers: true },
// });
// funscriptVibrate: funscriptKeys.vibrateKey,
// funscriptThrust: funscriptKeys.thrustKey,
const pb = await getPocketBaseClient();
const vod = await pb.collection('vods').getOne(vodId, {
expand: 'vtubers'
});
return vod;
}
function ensureVodReady(vod: Awaited<ReturnType<typeof getVod>>) {
if (vod.funscriptVibrate && vod.funscriptThrust) {
job.log(`Doing nothing-- vod ${vod.id} already has funscripts.`);
return false;
}
if (!vod.sourceVideo) {
const msg = `Cannot create funscript: Vod ${vod.id} is missing a source video.`;
job.log(msg);
throw new Error(msg);
}
return true;
}
async function downloadVideo(sourceVideo: string) {
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(
s3Client,
env.S3_BUCKET,
sourceVideo
);
job.log(`Downloaded video to ${videoFilePath}`);
return { s3Client, videoFilePath };
}
async function runInference(videoFilePath: string) {
job.log(`Running inference on video...`);
const predictionOutputPath = await inference(videoFilePath);
job.log(`Prediction output at ${predictionOutputPath}`);
return predictionOutputPath;
}
async function buildFunscripts(
predictionOutputPath: string,
videoFilePath: string
) {
job.log(`Building funscripts...`);
const vibratePath = await buildFunscript(
predictionOutputPath,
videoFilePath,
"vibrate"
);
const thrustPath = await buildFunscript(
predictionOutputPath,
videoFilePath,
"thrust"
);
job.log(
`Built funscripts: vibrate=${vibratePath}, thrust=${thrustPath}`
);
return { vibratePath, thrustPath };
}
async function uploadFunscripts(
s3Client: ReturnType<typeof getS3Client>,
slug: string,
streamDate: Date,
vodId: string,
funscripts: { vibratePath: string; thrustPath: string }
) {
const vibrateKey = generateS3Path(
slug,
streamDate,
vodId,
`funscripts/vibrate.funscript`
);
const vibrateUrl = await uploadFile(
s3Client,
env.S3_BUCKET,
vibrateKey,
funscripts.vibratePath,
"application/json"
);
const thrustKey = generateS3Path(
slug,
streamDate,
vodId,
`funscripts/thrust.funscript`
);
const thrustUrl = await uploadFile(
s3Client,
env.S3_BUCKET,
thrustKey,
funscripts.thrustPath,
"application/json"
);
job.log(`Uploaded funscriptVibrate to S3: ${vibrateUrl}`);
job.log(`Uploaded funscriptThrust to S3: ${thrustUrl}`);
return { vibrateKey, thrustKey };
}
async function saveToDatabase(
vodId: string,
funscriptKeys: { vibrateKey: string; thrustKey: string }
) {
const pb = await getPocketBaseClient();
await pb.collection('users').update(vodId, {
funscriptVibrate: funscriptKeys.vibrateKey,
funscriptThrust: funscriptKeys.thrustKey,
});
job.log(`Funscripts saved to database for vod ${vodId}`);
}
export async function createFunscriptTask(job: Job) {
const pb = await getPocketBaseClient();
job.log(`the job '${job.name}' is running`);
const { vodId } = job.data.vodId;
job.log(`createFunscript called with vodId=${vodId}`);
const vod = await getVod(vodId);
if (!ensureVodReady(vod)) return;
const { s3Client, videoFilePath } = await downloadVideo(vod.sourceVideo!);
const predictionOutputPath = await runInference(videoFilePath);
const slug = vod.vtubers[0].slug;
if (!slug)
throw new Error(`vod.vtubers[0].slug for vod ${vod.id} was falsy.`);
const funscripts = await buildFunscripts(
predictionOutputPath,
videoFilePath
);
const funscriptKeys = await uploadFunscripts(
s3Client,
slug,
vod.streamDate,
vod.id,
funscripts
);
await saveToDatabase(vodId, funscriptKeys);
};

View File

@ -1,220 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { basename, join, dirname } from "node:path";
import { mkdirp } from "fs-extra";
import { listFilesRecursive } from "../utils/filesystem";
import { getMimeType } from "../utils/mimetype";
import { getNanoSpawn } from "../utils/nanoSpawn";
import logger from "../utils/logger";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
interface HlsVariant {
videoPath: string;
resolution: string; // e.g. "1920x1080"
bandwidth: number; // in bits per second
mimetype: string;
}
// // Create a fragmented MP4 for use with fMP4 HLS
// async function createFragmentedMp4(helpers: Helpers, inputFilePath: string) {
// const outputFilePath = join(env.CACHE_ROOT, `${nanoid()}.mp4`)
// await spawn('ffmpeg', [
// '-i', inputFilePath,
// '-c', 'copy',
// '-f', 'mp4',
// '-movflags', 'frag_keyframe+empty_moov',
// outputFilePath
// ], {
// stdout: 'inherit',
// stderr: 'inherit',
// })
// return outputFilePath
// }
export async function createVariants(helpers: Helpers, inputFilePath: string): Promise<HlsVariant[]> {
const workdir = join(env.CACHE_ROOT, nanoid());
await mkdirp(workdir);
const baseName = basename(inputFilePath, '.mp4');
const spawn = await getNanoSpawn()
const resolutions = [
{ width: 1920, height: 1080, bitrate: 4000000, name: '1080p' }, // 4Mbps
{ width: 1280, height: 720, bitrate: 2500000, name: '720p' }, // 2.5Mbps
{ width: 854, height: 480, bitrate: 1000000, name: '480p' } // 1Mbps
];
const outputPaths: HlsVariant[] = [];
for (const { width, height, bitrate, name } of resolutions) {
const outputPath = join(workdir, `${baseName}_${name}.mp4`);
await spawn('ffmpeg', [
'-i', inputFilePath,
'-map', '0:v:0',
'-map', '0:a:0',
'-c:v', 'libx264',
'-preset', 'fast',
'-crf', '23',
'-b:v', `${bitrate}`,
'-s', `${width}x${height}`,
'-c:a', 'aac',
'-b:a', '128k',
outputPath
], {
stdout: 'inherit',
stderr: 'inherit',
});
outputPaths.push({
videoPath: outputPath,
bandwidth: bitrate,
resolution: `${width}x${height}`,
mimetype: 'video/mp4',
});
}
return outputPaths;
}
export async function packageHls(
helpers: Helpers,
variants: HlsVariant[],
outputDir: string
): Promise<string> {
const args: string[] = [];
const spawn = await getNanoSpawn();
// Sort by bandwidth (highest first)
variants.sort((a, b) => b.bandwidth - a.bandwidth);
// Video streams from all variants
for (const variant of variants) {
const baseName = basename(variant.videoPath, ".mp4");
const name = variant.resolution;
const videoOut = join(outputDir, `${baseName}_video.mp4`);
args.push(
`input=${variant.videoPath},stream=video,output=${videoOut},hls_name=video_${name},hls_group_id=video`
);
}
// Audio stream only once, from variants[0]
const best = variants[0];
const audioBase = basename(best.videoPath, ".mp4");
const audioOut = join(outputDir, `${audioBase}_audio.mp4`);
args.push(
`input=${best.videoPath},stream=audio,output=${audioOut},hls_name=audio,hls_group_id=audio`
);
const masterPlaylist = join(outputDir, "master.m3u8");
args.push(`--hls_master_playlist_output=${masterPlaylist}`);
args.push("--generate_static_live_mpd");
args.push("--segment_duration=2");
await spawn("packager", args, {
stdout: "inherit",
stderr: "inherit",
});
return masterPlaylist;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function createHlsPlaylist(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
}
})
// * [x] load vod
// * [x] exit if video.hlsPlaylist already defined
if (vod.hlsPlaylist) {
logger.info(`Doing nothing-- vod ${vodId} already has a hlsPlaylist.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create hlsPlaylist-- vod ${vodId} is missing a sourceVideo.`);
}
logger.info(`Creating HLS Playlist.`)
const s3Client = getS3Client()
const taskId = nanoid()
const workDirPath = join(env.CACHE_ROOT, taskId)
const packageDirPath = join(workDirPath, 'package', 'hls')
await mkdirp(packageDirPath)
logger.info("download source video from pull-thru cache")
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.info(`videoFilePath=${videoFilePath}`)
logger.info("create ABR variants")
const variants = await createVariants(helpers, videoFilePath)
logger.info('variants as follows')
logger.info(JSON.stringify(variants))
logger.info("run shaka packager")
const masterPlaylistPath = await packageHls(helpers, variants, packageDirPath)
logger.debug(`masterPlaylistPath=${masterPlaylistPath}`)
logger.info('uploading assets')
let assets = await listFilesRecursive(workDirPath)
logger.info('assets as follows')
logger.info(JSON.stringify(assets))
for (let i = 0; i < assets.length; i++) {
const asset = assets[i]
const s3Key = `package/${taskId}/hls/${basename(asset)}`
const mimetype = getMimeType(asset)
await uploadFile(s3Client, env.S3_BUCKET, s3Key, asset, mimetype)
};
logger.info("generate thumbnail s3 key")
const s3Key = `package/${taskId}/hls/master.m3u8`
// * [x] upload assets to s3
await uploadFile(s3Client, env.S3_BUCKET, s3Key, masterPlaylistPath, 'application/vnd.apple.mpegurl')
// await uploadFile(s3Client, env.S3_BUCKET, s3Key, masterPlaylistPath, 'application/vnd.apple.mpegurl')
// * [x] update vod record
await prisma.vod.update({
where: { id: vodId },
data: { hlsPlaylist: s3Key }
});
// * [x] done
}

View File

@ -1,100 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
import logger from "../utils/logger";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
function getCidFromStdout(output: string) {
// https://stackoverflow.com/questions/67176725/a-regex-json-schema-pattern-for-an-ipfs-cid
const match = output.match(/Qm[1-9A-HJ-NP-Za-km-z]{44,}|b[A-Za-z2-7]{58,}|B[A-Z2-7]{58,}|z[1-9A-HJ-NP-Za-km-z]{48,}|F[0-9A-F]{50,}/);
const cid = match ? match[0] : null;
return cid
}
async function hash(helpers: Helpers, inputFilePath: string) {
logger.info(`createIpfsCid with inputFilePath=${inputFilePath}`)
if (!inputFilePath) {
throw new Error("inputFilePath is missing");
}
const spawn = await getNanoSpawn();
const result = await spawn('ipfs', [
'add',
'--cid-version=1',
'--only-hash',
inputFilePath
]);
// const exitCode = await subprocess;
// if (exitCode !== 0) {
// logger.error(`vcsi failed with exit code ${exitCode}`);
// process.exit(exitCode);
// }
logger.info(JSON.stringify(result))
return getCidFromStdout(result.stdout)
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function createIpfsCid(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
}
})
// * [x] load vod
// * [x] exit if video.thumbnail already defined
if (vod.cidv1) {
logger.info(`Doing nothing-- vod ${vodId} already has a cidv1.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create cidv1-- vod ${vodId} is missing a sourceVideo.`);
}
logger.info('Creating CID')
const s3Client = getS3Client()
// * [x] download video segments from pull-thru cache
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.info(`videoFilePath=${videoFilePath}`)
// * [x] run ipfs to get a CID
const cidv1 = await hash(helpers, videoFilePath)
if (!cidv1) throw new Error(`cidv1 ${cidv1} was falsy`);
logger.info(`cidv1=${cidv1}`)
// * [x] update vod record
await prisma.vod.update({
where: { id: vodId },
data: { cidv1 }
});
}

View File

@ -1,114 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { basename, join } from "node:path";
import { mkdir } from "fs-extra";
import { generateClosedCaptions } from "../utils/transcription";
import logger from "../utils/logger";
import { create, type SLVTTOptions } from 'slvtt';
import { readdir } from "node:fs/promises";
import { concurrency } from "sharp";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function createStoryboard(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
},
select: {
sourceVideo: true,
slvttSheetKeys: true,
slvttVTTKey: true,
},
})
const taskId = nanoid()
if (vod.slvttVTTKey) {
logger.info(`Doing nothing-- vod ${vodId} already has a slvttVTTKey.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create vtt-- vod ${vodId} is missing a sourceVideo.`);
}
const s3Client = getS3Client()
logger.debug(`downloading ${vod.sourceVideo}. CACHE_ROOT=${env.CACHE_ROOT}`)
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.debug(`running slvtt on VOD vodId=${vodId}, videoFilePath=${videoFilePath}`)
const outputDirectory = join(env.CACHE_ROOT, nanoid())
const options: SLVTTOptions = {
videoFilePath,
outputDirectory,
cols: 9,
rows: 6,
frameHeight: 78,
frameWidth: 140,
concurrencyLimit: 15,
numSamples: 696,
}
await create(options);
logger.debug(`Uploading slvtt videoFrameSheets for vodId=${vodId}`)
const files = await readdir(outputDirectory)
const sheets = files.filter((f) => f.endsWith('.webp'));
const vtts = files.filter((f) => f.endsWith('.vtt'))
if (vtts.length === 0) {
throw new Error('No .vtt found in the slvtt output. This should never happen.');
}
logger.debug(`slvtt created the following files.`)
logger.debug(files)
let slvttSheetKeys: string[] = [];
for (const sheet of sheets) {
logger.debug(`Uploading sheet=${JSON.stringify(sheet)}`);
let sheetKey = await uploadFile(s3Client, env.S3_BUCKET, `slvtt/${taskId}/${sheet}`, join(outputDirectory, sheet), 'image/webp')
slvttSheetKeys.push(sheetKey);
}
logger.debug(`Uploading slvtt vtt for vodId=${vodId}`)
const vtt = vtts[0]
const slvttVTTKey = await uploadFile(s3Client, env.S3_BUCKET, `slvtt/${taskId}/${vtt}`, join(outputDirectory, vtt), 'text/vtt')
logger.debug(`updating vod ${vodId} record. slvttSheetKeys=${JSON.stringify(slvttSheetKeys)}, slvttVTTKey=${slvttVTTKey}`);
await prisma.vod.update({
where: { id: vodId },
data: {
slvttSheetKeys,
slvttVTTKey,
}
});
}

View File

@ -1,206 +0,0 @@
/**
*
* # createTorrent
*
* ## notes
*
* ### torrents
* downloading a random sample of linux torrents,
* I see that most people create their torrent files using
* * transmission
* * mktorrent
* * qbittorrent
*
* ### .note
* FYI, this module creates two .node files when bundled by tsup.
* Graphile worker will warn about not being able to handle these.
* (ignore the warnings.)
*
*/
import type { Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
import logger from "../utils/logger";
import { basename, join } from "node:path";
import { generateS3Path } from "../utils/formatters";
import { sshClient } from "../utils/sftp";
import { qbtClient } from "../utils/qbittorrent/qbittorrent";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error(`invalid payload-- ${JSON.stringify(payload)} was missing vodId`);
}
// async function createImdlTorrent(
// vodId: string,
// videoFilePath: string
// ): Promise<{ magnetLink: string; torrentFilePath: string }> {
// const spawn = await getNanoSpawn()
// const torrentFilePath = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
// logger.debug('creating torrent & magnet link via imdl');
// const result = await spawn('imdl', [
// 'torrent',
// 'create',
// '--input', videoFilePath,
// '--link',
// '--output', torrentFilePath,
// ], {
// cwd: env.APP_DIR,
// });
// logger.trace(JSON.stringify(result));
// const match = result.stdout.match(/magnet:\?[^\s]+/);
// if (!match) {
// throw new Error('No magnet link found in imdl output:\n' + result.stdout);
// }
// const magnetLink = match[0];
// logger.debug(`Magnet link=${magnetLink}`);
// return {
// magnetLink,
// torrentFilePath
// }
// }
async function createQBittorrentTorrent(
vodId: string,
videoFilePath: string
): Promise<{
magnetLink: string,
torrentFilePath: string
}> {
const torrentInfo = await qbtClient.createTorrent(videoFilePath);
return torrentInfo
}
// async function createTorrentfileTorrent(
// vodId: string,
// videoFilePath: string
// ): Promise<{ magnetLink: string; torrentFilePath: string }> {
// const spawn = await getNanoSpawn()
// // * [x] run torrentfile
// // torrentfile create
// // --magnet
// // --prog 0
// // --out ./test-fixture.torrent
// // --announce udp://tracker.futureporn.net/
// // --source https://futureporn.net/
// // --comment https://futureporn.net/
// // --meta-version 3
// // ~/Downloads/test-fixture.ts
// const torrentFilePath = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
// logger.debug('creating torrent & magnet link')
// const result = await spawn('torrentfile', [
// 'create',
// '--magnet',
// '--prog', '0',
// '--meta-version', '3', // torrentfile creates invalid hybrid torrents!
// '--out', torrentFilePath,
// videoFilePath,
// ], {
// cwd: env.APP_DIR,
// });
// logger.trace(JSON.stringify(result));
// const match = result.stdout.match(/magnet:\?[^\s]+/);
// if (!match) {
// throw new Error('No magnet link found in torrentfile output:\n' + result.stdout);
// }
// const magnetLink = match[0];
// logger.debug(`Magnet link=${magnetLink}`);
// return {
// magnetLink,
// torrentFilePath
// }
// }
async function uploadTorrentToSeedbox(videoFilePath: string, torrentFilePath: string) {
await sshClient.uploadFile(videoFilePath, './data');
await sshClient.uploadFile(torrentFilePath, './watch');
}
export default async function main(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
}
})
// const spawn = await getNanoSpawn();
// * [x] load vod
// * [x] exit if video.thumbnail already defined
if (vod.magnetLink) {
logger.info(`Doing nothing-- vod ${vodId} already has a magnet link.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create magnet link-- vod ${vodId} is missing a sourceVideo.`);
}
logger.info('Creating torrent.')
const s3Client = getS3Client()
// * [x] download video segments from pull-thru cache
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.debug(`videoFilePath=${videoFilePath}`)
const { magnetLink, torrentFilePath } = await createQBittorrentTorrent(vodId, videoFilePath)
await uploadTorrentToSeedbox(videoFilePath, torrentFilePath)
logger.debug(`updating vod record`);
await prisma.vod.update({
where: { id: vodId },
data: { magnetLink }
});
logger.info(`🏆 torrent creation complete.`)
}

View File

@ -1,86 +0,0 @@
/** ideas
* - https://github.com/ggml-org/whisper.cpp/tree/master/examples/cli
* - https://whisperapi.com
* - https://elevenlabs.io/pricing
* - https://easy-peasy.ai/#pricing
* - https://www.clipto.com/pricing
* - https://github.com/m-bain/whisperX
* - https://github.com/kaldi-asr/kaldi
* - https://github.com/usefulsensors/moonshine
* - https://docs.bunny.net/reference/video_transcribevideo
*/
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { basename, join } from "node:path";
import { mkdir } from "fs-extra";
import { generateClosedCaptions } from "../utils/transcription";
import logger from "../utils/logger";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function transcribeVideo(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
},
select: {
sourceVideo: true,
asrVttKey: true,
},
})
if (vod.asrVttKey) {
logger.info(`Doing nothing-- vod ${vodId} already has a vtt.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create vtt-- vod ${vodId} is missing a sourceVideo.`);
}
const s3Client = getS3Client()
logger.debug(`download video segments from pull-thru cache`)
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.debug(`Transcribing VOD vodId=${vodId}, videoFilePath=${videoFilePath}`)
const captionsFilePath = await generateClosedCaptions(videoFilePath)
const captionsFileBasename = basename(captionsFilePath)
logger.debug(`Uploading closed captions for vodId=${vodId}`)
const asrVttKey = await uploadFile(s3Client, env.S3_BUCKET, captionsFileBasename, captionsFilePath, 'text/vtt')
logger.debug(`updating vod ${vodId} record. asrVttKey=${asrVttKey}`);
await prisma.vod.update({
where: { id: vodId },
data: { asrVttKey }
});
}

View File

@ -1,126 +0,0 @@
import type { Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client, uploadFile } from "../utils/s3";
import { nanoid } from "nanoid";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { generateS3Path } from "../utils/formatters";
import logger from "../utils/logger";
import { preparePython } from "../utils/python";
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
async function createThumbnail(helpers: Helpers, inputFilePath: string) {
logger.debug(`createThumbnail with inputFilePath=${inputFilePath}`)
if (!inputFilePath) {
throw new Error("inputFilePath is missing");
}
const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '-thumb.png';
const spawn = await getNanoSpawn();
const result = await spawn('vcsi', [
inputFilePath,
'--metadata-position', 'hidden',
'--metadata-margin', '0',
'--metadata-horizontal-margin', '0',
'--metadata-vertical-margin', '0',
'--grid-spacing', '0',
'--grid-horizontal-spacing', '0',
'--grid-vertical-spacing', '0',
'--timestamp-horizontal-margin', '0',
'--timestamp-vertical-margin', '0',
'--timestamp-horizontal-padding', '0',
'--timestamp-vertical-padding', '0',
'-w', '830',
'-g', '5x5',
'-o', outputFilePath
], {
stdout: 'inherit',
stderr: 'inherit',
cwd: env.APP_DIR,
});
logger.debug('result as follows')
logger.debug(JSON.stringify(result, null, 2))
logger.info(`✅ Thumbnail saved to: ${outputFilePath}`);
return outputFilePath
}
function assertPayload(payload: any): asserts payload is Payload {
if (typeof payload !== "object" || !payload) throw new Error("invalid payload-- was not an object.");
if (typeof payload.vodId !== "string") throw new Error("invalid payload-- was missing vodId");
}
export default async function createVideoThumbnail(payload: any, helpers: Helpers) {
assertPayload(payload)
const { vodId } = payload
const vod = await prisma.vod.findFirstOrThrow({
where: {
id: vodId
},
include: {
vtubers: {
select: {
slug: true,
id: true,
}
}
}
})
// * [x] load vod
// * [x] exit if video.thumbnail already defined
if (vod.thumbnail) {
logger.info(`Doing nothing-- vod ${vodId} already has a thumbnail.`)
return; // Exit the function early
}
if (!vod.sourceVideo) {
throw new Error(`Failed to create thumbnail-- vod ${vodId} is missing a sourceVideo.`);
}
logger.info('Creating Video Thumbnail')
const s3Client = getS3Client()
// * [x] download video segments from pull-thru cache
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo)
logger.debug(`videoFilePath=${videoFilePath}`)
// * [x] run vcsi
const thumbnailPath = await createThumbnail(helpers, videoFilePath)
logger.debug(`thumbnailPath=${thumbnailPath}`)
// * [x] generate thumbnail s3 key
const slug = vod.vtubers[0].slug
if (!slug) throw new Error(`vtuber ${vod.vtubers[0].id} was missing slug`);
const s3Key = generateS3Path(slug, vod.streamDate, vod.id, `thumbnail.png`);
// * [x] upload thumbnail to s3
await uploadFile(s3Client, env.S3_BUCKET, s3Key, thumbnailPath, 'image/png')
// * [x] update vod record
await prisma.vod.update({
where: { id: vodId },
data: { thumbnail: s3Key }
});
// * [x] done
}

View File

@ -1,30 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate";
import logger from "../utils/logger";
const prisma = new PrismaClient().$extends(withAccelerate());
const findWork: Task = async (_payload, helpers: Helpers) => {
logger.info(`findWork begin.`);
const approvedUploads = await prisma.vod.findMany({
where: {
OR: [
{ status: "approved" },
{ status: "pending" },
]
},
});
logger.info(`findWork found ${approvedUploads.length} uploads.`);
for (let i = 0; i < approvedUploads.length; i++) {
const vod = approvedUploads[i];
await helpers.addJob("scheduleVodProcessing", { vodId: vod.id });
logger.info(`scheduleVodProcessing for vod ${vod.id}`);
}
logger.info(`findWork finished.`);
};
export default findWork;

View File

@ -1,70 +0,0 @@
import type { Task } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { createHash } from 'crypto';
import { access } from "node:fs/promises";
import { pipeline } from 'stream/promises';
import path from 'path';
import { createReadStream } from "node:fs";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { getS3Client } from "../utils/s3";
import logger from "../utils/logger";
const prisma = new PrismaClient();
interface Payload {
vodId: string;
}
const client = getS3Client()
const generateVideoChecksum: Task = async (payload: unknown, helpers) => {
const { vodId } = payload as Payload;
logger.info(`Generating checksum for VOD ${vodId}`);
// 1. Get VOD record with source video path
const vod = await prisma.vod.findUnique({
where: { id: vodId },
select: { sourceVideo: true }
});
if (!vod?.sourceVideo) {
throw new Error(`VOD ${vodId} has no source video`)
}
// 2. Verify file exists
const videoPath = await getOrDownloadAsset(client, env.S3_BUCKET, vod.sourceVideo)
logger.info(`videoPath=${videoPath}`)
try {
await access(videoPath);
} catch (err) {
throw new Error(`Source video not found at ${videoPath}`);
}
// 3. Generate SHA-256 hash
try {
const hash = createHash('sha256');
const fileStream = createReadStream(videoPath);
await pipeline(
fileStream,
hash
);
const checksum = hash.digest('hex');
logger.info(`Generated checksum for ${path.basename(vod.sourceVideo)}: ${checksum}`);
// 4. Update VOD record
await prisma.vod.update({
where: { id: vodId },
data: { sha256sum: checksum }
});
} catch (err) {
logger.error(`Failed to generate checksum: ${err.message}`);
throw err; // Will trigger retry if configured
}
};
export default generateVideoChecksum;

View File

@ -1,235 +0,0 @@
import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { access, stat, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env";
import { nanoid } from "nanoid";
import { uploadFile } from "../utils/s3";
import { S3Client } from "@aws-sdk/client-s3";
import { VodSegment } from "../types";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { parseISO, getYear, getMonth, getDate } from 'date-fns';
import logger from "../utils/logger";
const prisma = new PrismaClient();
const client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_KEY_ID,
secretAccessKey: env.S3_APPLICATION_KEY,
},
});
interface Payload {
vodId: string;
}
type FFmpegConcatSpec = {
files: string[];
inputPath: string;
outputPath: string;
};
function assertPayload(payload: unknown): asserts payload is Payload {
if (typeof payload !== "object" || !payload) {
throw new Error("Invalid payload - expected an object");
}
if (typeof (payload as Payload).vodId !== "string") {
throw new Error("Invalid payload - missing or invalid vodId");
}
}
async function validateSegments(segments: VodSegment[], helpers: Helpers) {
if (!segments || segments.length === 0) {
throw new Error("No VOD segments provided");
}
logger.info(`Processing ${segments.length} video segments`);
}
async function downloadSegments(
segmentKeys: VodSegment[],
helpers: Helpers
): Promise<string[]> {
const downloadedPaths: string[] = [];
for (const [index, segment] of segmentKeys.entries()) {
try {
logger.debug(`Downloading segment ${index + 1}/${segmentKeys.length}`);
const path = await getOrDownloadAsset(client, env.S3_BUCKET, segment.key);
downloadedPaths.push(path);
// Verify the segment exists and is accessible
await access(path);
const size = await getFileSize(path);
logger.debug(`Segment ${index + 1} size: ${(size / 1024 / 1024).toFixed(2)}MB`);
} catch (error) {
throw new Error(`Failed to download segment ${segment.key}: ${error instanceof Error ? error.message : String(error)}`);
}
}
return downloadedPaths;
}
async function createConcatSpecFile(
filePaths: string[],
concatSpec: FFmpegConcatSpec
): Promise<void> {
const concatSpecContent = filePaths
.map((f) => `file '${f.replace(/'/g, "'\\''")}'`) // Properly escape single quotes
.join("\n")
.concat("\n");
await writeFile(concatSpec.inputPath, concatSpecContent, "utf8");
}
async function concatenateSegments(
concatSpec: FFmpegConcatSpec,
helpers: Helpers
): Promise<string> {
logger.info(`Concatenating ${concatSpec.files.length} segments`);
try {
const spawn = await getNanoSpawn();
const proc = await spawn("ffmpeg", [
"-f", "concat",
"-safe", "0",
"-i", concatSpec.inputPath,
"-c", "copy", // Stream copy (no re-encoding)
"-movflags", "+faststart", // Enable streaming
concatSpec.outputPath
], {
cwd: env.CACHE_ROOT,
});
logger.debug(`FFmpeg output: ${proc.stdout}`);
logger.debug(`FFmpeg stderr: ${proc.stderr}`);
// Verify output file
await access(concatSpec.outputPath);
const outputSize = await getFileSize(concatSpec.outputPath);
logger.info(`Concatenated file size: ${(outputSize / 1024 / 1024).toFixed(2)}MB`);
return concatSpec.outputPath;
} catch (error) {
throw new Error(`FFmpeg concatenation failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async function updateVodWithSourceVideo(
vodId: string,
sourcePath: string,
helpers: Helpers
): Promise<void> {
logger.info(`Updating VOD ${vodId} with source video ${sourcePath}`);
await prisma.vod.update({
where: { id: vodId },
data: {
sourceVideo: sourcePath,
status: "processing"
},
});
}
export async function getFileSize(filePath: string): Promise<number> {
const stats = await stat(filePath);
return stats.size;
}
function isVodSegmentArray(value: unknown): value is VodSegment[] {
return Array.isArray(value) && value.every(
item => item && typeof item === 'object' && typeof item.key === 'string' && typeof item.name === 'string'
);
}
/**
getSourceVideo
Download its segments from S3.
(Optional) concatenate them using ffmpeg.
Upload the resulting video back to S3.
Update the VOD record in the database.
*/
const getSourceVideo: Task = async (payload: unknown, helpers) => {
assertPayload(payload);
const { vodId } = payload;
logger.info(`Processing source video for VOD ${vodId}`);
try {
// Get VOD info from database
const vod = await prisma.vod.findFirstOrThrow({
where: { id: vodId },
select: {
sourceVideo: true,
segmentKeys: true,
status: true,
vtubers: true,
streamDate: true,
},
});
// Skip if already processed
if (vod.sourceVideo) {
logger.debug(`VOD ${vodId} already has a source video`);
return;
}
if (!isVodSegmentArray(vod.segmentKeys) || vod.segmentKeys.length === 0) {
throw new Error(`Invalid or missing segmentKeys array for VOD ${vodId}: ${JSON.stringify(vod.segmentKeys)}`);
}
const segments: VodSegment[] = vod.segmentKeys;
// Log and validate the segments
await validateSegments(segments, helpers);
// Download all segments
const downloadedPaths = await downloadSegments(segments, helpers);
// Process segments
let sourceVideoPath: string;
if (downloadedPaths.length === 1) {
// Single segment - no concatenation needed
sourceVideoPath = downloadedPaths[0];
logger.info(`Using single segment as source video`);
} else {
// Multiple segments - concatenate
const concatSpec: FFmpegConcatSpec = {
files: downloadedPaths,
inputPath: join(env.CACHE_ROOT, `${nanoid()}-files.txt`),
outputPath: join(env.CACHE_ROOT, `${nanoid()}-concatenated.mp4`),
};
await createConcatSpecFile(downloadedPaths, concatSpec);
sourceVideoPath = await concatenateSegments(concatSpec, helpers);
}
// upload the concatenated video
const year = getYear(vod.streamDate);
const month = getMonth(vod.streamDate) + 1;
const day = getDate(vod.streamDate);
const key = await uploadFile(client, env.S3_BUCKET, `fp/${vod.vtubers[0].slug}/${year}/${month}/${day}/${nanoid()}/source.mp4`, sourceVideoPath, 'video/mp4');
// Update database with source video path
await updateVodWithSourceVideo(vodId, key, helpers);
logger.info(`Successfully processed source video for VOD ${vodId}`);
} catch (error) {
logger.error(`Failed to process source video for VOD ${vodId}: ${error instanceof Error ? error.message : String(error)}`);
// await prisma.vod.update({
// where: { id: vodId },
// data: { status: "failed" },
// });
throw error;
}
};
export default getSourceVideo;

View File

@ -1,133 +0,0 @@
import type { Task } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma";
import { env } from "../config/env";
import { S3Client } from "@aws-sdk/client-s3";
import logger from "../utils/logger";
import { getNanoSpawn } from "../utils/nanoSpawn";
import { getOrDownloadAsset } from "../utils/cache";
import { getS3Client } from "../utils/s3";
const prisma = new PrismaClient();
const client = new S3Client({
region: env.S3_REGION,
endpoint: env.S3_ENDPOINT,
credentials: {
accessKeyId: env.S3_KEY_ID,
secretAccessKey: env.S3_APPLICATION_KEY,
},
});
interface Payload {
vodId: string;
}
export interface VideoMetadata {
durationMs: number; // duration in ms
fps: number | null; // frames per second
videoCodec: string | null;
audioCodec: string | null;
}
export async function getVideoMetadata(filePath: string): Promise<VideoMetadata> {
const spawn = await getNanoSpawn()
const { stdout } = await spawn("ffprobe", [
"-v", "error",
"-show_streams",
"-show_format",
"-of", "json",
filePath,
]);
const data = JSON.parse(stdout);
// --- duration ---
const durationSec = parseFloat(data.format?.duration ?? "0");
const durationMs = Math.round(durationSec * 1000);
// --- video stream ---
const videoStream = data.streams.find((s: any) => s.codec_type === "video");
let fps: number | null = null;
if (videoStream?.r_frame_rate) {
const [num, den] = videoStream.r_frame_rate.split("/").map(Number);
if (den > 0) fps = num / den;
}
// --- codecs ---
const videoCodec = videoStream?.codec_name ?? null;
const audioCodec =
data.streams.find((s: any) => s.codec_type === "audio")?.codec_name ?? null;
return {
durationMs,
fps,
videoCodec,
audioCodec,
};
}
function assertPayload(payload: unknown): asserts payload is Payload {
if (typeof payload !== "object" || !payload) {
throw new Error("Invalid payload - expected an object");
}
if (typeof (payload as Payload).vodId !== "string") {
throw new Error("Invalid payload - missing or invalid vodId");
}
}
const getSourceVideoMetadata: Task = async (payload: unknown, helpers) => {
assertPayload(payload);
const { vodId } = payload;
logger.info(`Processing video metadata for vod ${vodId}`);
try {
const vod = await prisma.vod.findFirstOrThrow({
where: { id: vodId },
select: {
sourceVideo: true,
sourceVideoDuration: true,
},
});
// Skip if already processed
if (
vod.sourceVideoDuration
) {
logger.debug(`VOD ${vodId} already has metadata`);
return;
}
if (!vod.sourceVideo) {
throw new Error(`VOD ${vodId} has no sourceVideo`);
}
// download vod
const s3Client = getS3Client();
const videoFilePath = await getOrDownloadAsset(s3Client, env.S3_BUCKET, vod.sourceVideo);
logger.info(`videoFilePath=${videoFilePath}`);
// Run ffprobe
const meta = await getVideoMetadata(videoFilePath);
// Update DB
await prisma.vod.update({
where: { id: vodId },
data: {
sourceVideoDuration: meta.durationMs,
sourceVideoFps: meta.fps,
sourceVideoCodec: meta.videoCodec,
sourceAudioCodec: meta.audioCodec,
},
});
logger.info(`Updated VOD ${vodId} with metadata: ${JSON.stringify(meta)}`);
} catch (err) {
logger.error({ err }, `Failed to process metadata for VOD ${vodId}`);
throw err; // let Graphile retry
}
};
export default getSourceVideoMetadata;

View File

@ -1,14 +0,0 @@
import { childMq } from '../queues/childQueue.ts';
export async function parentTask(data: any) {
console.log('Parent task starting...', data);
// imagine this parent job splits work into chunks
const chunks = ['A', 'B', 'C'];
for (const chunk of chunks) {
await childMq.add('childJob', { chunk, parent: data.id });
}
console.log(`Queued ${chunks.length} child jobs for parent ${data.id}`);
}

View File

@ -1,74 +0,0 @@
import Mux from '@mux/mux-node';
import env from '../../.config/env';
import { type QueueOptions, type Job } from 'bullmq';
import { getPocketBaseClient } from '../util/pocketbase';
const mux = new Mux({
tokenId: env.MUX_TOKEN_ID,
tokenSecret: env.MUX_TOKEN_SECRET
});
async function createToken(playbackId: string) {
// Set some base options we can use for a few different signing types
// Type can be either video, thumbnail, gif, or storyboard
let baseOptions = {
keyId: env.MUX_SIGNING_KEY_ID, // Enter your signing key id here
keySecret: env.MUX_SIGNING_KEY_PRIVATE_KEY, // Enter your base64 encoded private key here
expiration: '7d', // E.g 60, "2 days", "10h", "7d", numeric value interpreted as seconds
};
const token = await mux.jwt.signPlaybackId(playbackId, { ...baseOptions, type: 'video' });
return token
}
export async function presignMuxAssets(job: Job) {
const pb = await getPocketBaseClient();
job.log(`the job '${job.name}' is running`);
// const userData = await pb.collection('_superusers').authWithPassword(env.POCKETBASE_USERNAME, env.POCKETBASE_PASSWORD);
// job.log(`userData ${JSON.stringify(userData)}`);
// @todo presign all mux assets
// 1. for each VOD in pocketbase
// 2. get the muxPlaybackId
const vods = await pb.collection('vods').getFullList({
sort: '-created',
});
job.log(`there are ${vods.length} vods`);
// job.log(JSON.stringify(vods, null, 2));
// 3. sign the muxPlaybackId
for (let i = 0; i < vods.length; i++) {
const vod = vods[i];
if (!vod) throw new Error(`vod ${i} missing`);
if (vod.muxPlaybackId) {
const muxPlaybackToken = await createToken(vod.muxPlaybackId);
await pb.collection('vods').update(vod.id, {
muxPlaybackToken
});
}
// Calculate progress as a percentage
const progress = Math.round(((i + 1) / vods.length) * 100);
await job.updateProgress(progress);
}
job.log(`presign complete.`);
return { complete: true, presignCount: vods.length };
}

View File

@ -1,82 +0,0 @@
import { UnrecoverableError, type Job } from "bullmq";
import { getPocketBaseClient } from "../util/pocketbase";
import { generalQueue } from "../queues/generalQueue";
interface Payload {
vodId: string;
}
const RECHECK_DELAY_SECONDS = 300;
/**
*
* scheduleVodProcessing identities vod processing tasks needed to be run, and schedules those tasks.
*
*/
function isPayloadValid(payload: unknown): payload is Payload {
return !!payload && typeof payload === "object" && "vodId" in payload;
}
export async function scheduleVodProcessingTask(job: Job) {
if (!isPayloadValid(job.data)) {
throw new Error("Invalid job payload: expected { vodId: string }");
}
const { vodId } = job.data;
job.log(`Starting processing for VOD ${vodId}`);
const pb = await getPocketBaseClient();
// const vod = await prisma.vod.findUnique({ where: { id: vodId } });
const vod = await pb.collection('vods').getOne(vodId);
if (!vod) {
job.log(`VOD not found: ${vodId}`);
throw new UnrecoverableError("VOD not found");
}
// Schedule required jobs
const jobs: Promise<Job>[] = [];
// await childMq.add('childJob', { chunk, parent: data.id });
// jobs.push(helpers.addJob("copyV1S3ToV2", { vodId }));
// if (!vod.sourceVideo) jobs.push(helpers.addJob("getSourceVideo", { vodId }));
// if (!vod.sourceVideoDuration) jobs.push(helpers.addJob("getSourceVideoMetadata", { vodId }))
if (!vod.audioIntegratedLufs || !vod.audioLoudnessRange || !vod.audioTruePeak) {
await generalQueue.add('analyzeAudio', { vodId }); // jobs.push(helpers.addJob("analyzeAudio", { vodId }))
}
// if (!vod.sha256sum) jobs.push(helpers.addJob("generateVideoChecksum", { vodId }));
// if (!vod.thumbnail) jobs.push(helpers.addJob("createVideoThumbnail", { vodId }));
// if (!vod.hlsPlaylist) jobs.push(helpers.addJob("createHlsPlaylist", { vodId }));
// if (!vod.cidv1) jobs.push(helpers.addJob("createIpfsCid", { vodId }));
// if (!vod.funscriptVibrate || !vod.funscriptThrust) jobs.push(helpers.addJob("createFunscript", { vodId }));
// if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
// if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
// if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
const changes = jobs.length;
if (changes > 0) {
await Promise.all(jobs);
await pb.collection('vods').update(vodId, { status: 'processing' })
job.log(`Scheduled ${changes} jobs for VOD ${vodId}`);
// Schedule next check
// @huh? @todo IDK what is up with this, but it seems to run right away even though it has the runAt defined.
// @huh? @todo Because it runs immediately, this makes it an infinite loop. Disabling for now.
// await helpers.addJob("scheduleVodProcessing", {
// vodId,
// runAt: addMinutes(new Date(), 1) // Check again in 1 minute
// });
} else {
// All jobs completed - finalize
await pb.collection('vods').update(vodId, { status: 'processed' })
job.log(`All processing complete for VOD ${vodId}`);
}
};

View File

@ -1,207 +0,0 @@
import { type Job } from 'bullmq';
import { getPocketBaseClient } from '../util/pocketbase';
import { subMinutes } from 'date-fns'; // optional, or use plain JS Date
interface PatreonMember {
id: string;
type: 'member';
attributes: Record<string, unknown>;
relationships: {
currently_entitled_tiers: {
data: PatreonTierRef[];
};
user: {
data: PatreonUserRef;
links?: {
related?: string;
};
};
};
}
export interface PatreonTierRef {
id: string;
type: 'tier';
}
export interface PatreonUserRef {
id: string;
type: 'user';
}
export type PatreonIncluded = PatreonUser | PatreonTier | PatreonMember;
export interface PatreonUser {
id: string;
type: 'user';
attributes: {
full_name: string;
vanity: string | null;
};
}
export interface PatreonTier {
id: string;
type: 'tier';
attributes: Record<string, unknown>;
}
export interface PatreonUserResponse {
data: PatreonUserData;
included?: PatreonIncluded[];
links?: {
self?: string;
};
}
export interface PatreonUserData {
id: string;
type: 'user';
attributes: {
email: string;
first_name: string;
last_name: string;
full_name: string;
vanity: string | null;
about: string | null;
image_url: string;
thumb_url: string;
created: string; // ISO date string
url: string;
};
relationships?: {
memberships?: {
data: {
id: string;
type: 'member';
}[];
};
};
}
export interface PatreonTier {
id: string;
type: 'tier';
attributes: Record<string, unknown>;
relationships?: {
benefits?: {
data: { id: string; type: string }[];
};
};
}
export const PatreonTiers = [
{ name: 'ArchiveSupporter', id: '8154170', role: 'supporterTier1' },
{ name: 'StealthSupporter', id: '9561793', role: 'supporterTier1' },
{ name: 'TuneItUp', id: '9184994', role: 'supporterTier2' },
{ name: 'MaxQ', id: '22529959', role: 'supporterTier3' },
{ name: 'ArchiveCollector', id: '8154171', role: 'supporterTier4' },
{ name: 'AdvancedArchiveSupporter', id: '8686045', role: 'supporterTier4' },
{ name: 'QuantumSupporter', id: '8694826', role: 'supporterTier5' },
{ name: 'SneakyQuantumSupporter', id: '9560538', role: 'supporterTier5' },
{ name: 'LuberPlusPlus', id: '8686022', role: 'supporterTier6' }
];
interface PatreonMember {
userId: string;
}
// small output type
export interface SimplePatreonMember { userId: string }
export async function getPatreonPatronStatus(
job: Job,
accessToken: string
): Promise<boolean> {
const url =
'https://www.patreon.com/api/oauth2/v2/identity?fields%5Buser%5D=about,created,email,first_name,full_name,image_url,last_name,thumb_url,url,vanity&include=memberships,memberships.currently_entitled_tiers,memberships.currently_entitled_tiers.benefits';
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!res.ok) {
throw new Error(`Patreon API error: ${res.status} ${res.statusText}`);
}
const data = (await res.json()) as PatreonUserResponse;
const included = data.included ?? [];
const memberObjects = included.filter((item) => item.type === 'member');
job.log(`Found ${memberObjects.length} membership(s)`);
const validTierIds = PatreonTiers.map((tier) => tier.id);
for (const member of memberObjects) {
const tiers = member.relationships?.currently_entitled_tiers?.data ?? [];
job.log(`Membership ${member.id} has tiers: ${tiers.map(t => t.id)}`);
// Check if any tier is in our whitelist
const hasValidTier = tiers.some((tier) => validTierIds.includes(tier.id));
if (hasValidTier) {
job.log(`User has a valid Patreon tier: ${tiers.map(t => t.id).join(', ')}`);
return true;
}
}
job.log('No valid Patreon tiers found for this user');
return false;
}
async function idempotentlySetUserPatronStatus(
userId: string,
isPatron: boolean
): Promise<void> {
const pb = await getPocketBaseClient();
const presentUser = await pb.collection('users').getOne(userId);
// Only update if the value is actually different
if (presentUser.patron !== isPatron) {
await pb.collection('users').update(userId, {
patron: isPatron
});
}
}
export async function syncronizePatreon(job: Job) {
const pb = await getPocketBaseClient();
job.log('WE GOT pb CLIENT, WOOHOO!');
job.log(`the job '${job.name}' is running`);
const fewMinutesAgo = subMinutes(new Date(), 1);
const recentlyLoggedInUsers = await pb.collection('users').getFullList({
filter: pb.filter('updated>={:since}', { since: fewMinutesAgo })
});
job.log(`recentlyLoggedInUsers:${JSON.stringify(recentlyLoggedInUsers)}`);
let results = []
for (const user of recentlyLoggedInUsers) {
const isPatron = await getPatreonPatronStatus(job, user.patreonAccessToken);
await idempotentlySetUserPatronStatus(user.id, isPatron);
results.push({ user: user.id, isPatron })
}
return { complete: true, results };
}

View File

@ -1,27 +0,0 @@
import { Queue } from 'bullmq';
export const generalQueue = new Queue('generalQueue');
await generalQueue.upsertJobScheduler(
'every-day-presign-mux-job',
{
pattern: '3 7 * * *', // Runs at 07:03 every day
},
{
name: 'presignMuxAsset',
data: {},
opts: {}, // Optional additional job options
},
);
// await generalQueue.upsertJobScheduler(
// 'copy-v2',
// {
// pattern: '* * * * *', // Runs at 07:03 every day
// },
// {
// name: 'copyV2ThumbToV3',
// data: {},
// opts: {}, // Optional additional job options
// },
// );

View File

@ -1,17 +0,0 @@
import { Queue } from "bullmq";
export const gpuQueue = new Queue('gpuQueue');
await gpuQueue.upsertJobScheduler(
'schedule-vod-processing-recurring',
{
pattern: '* * * * *'
},
{
name: 'cron-schedule-vod-processing',
data: {},
opts: {}
},
)

View File

@ -1,14 +0,0 @@
import { Queue } from "bullmq";
export const highPriorityQueue = new Queue('highPriorityQueue');
await highPriorityQueue.upsertJobScheduler(
'sync-patreon-recurring-job',
{
every: 45000
},
{
name: 'syncronizePatreon',
data: {},
opts: {}
},
)

View File

@ -1,4 +0,0 @@
import { Queue } from 'bullmq';
import { connection } from '../../.config/bullmq.config.ts';
export const parentMq = new Queue('parentMq', { connection });

View File

@ -1,7 +0,0 @@
import spawn from 'nano-spawn';
import env from '../../.config/env';
export async function b2Download(fromS3Key: string, tmpFilePath: string): Promise<string> {
await spawn('b2', ['file', 'download', `b2://${env.AWS_BUCKET}/${fromS3Key}`, tmpFilePath]);
return fromS3Key;
}

View File

@ -1,54 +0,0 @@
import slugify from 'slugify'
import { getYear, getMonth, getDate } from 'date-fns';
export function toJsonSafe<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
export function slug(s: string) {
return slugify(s, {
replacement: '-', // replace spaces with replacement character, defaults to `-`
remove: undefined, // remove characters that match regex, defaults to `undefined`
lower: true, // convert to lower case, defaults to `false`
strict: true, // strip special characters except replacement, defaults to `false`
locale: 'en', // language code of the locale to use
trim: true // trim leading and trailing replacement chars, defaults to `true`
})
}
export function truncate(text: string, n: number = 6) {
if (typeof text !== 'string') return '';
return text.length > n ? text.slice(0, n) + '…' : text;
}
function pad(n: number): string {
return n.toString().padStart(2, '0');
}
export function generateS3Path(slug: string, date: Date, vodId: string, filename: string): string {
const year = getYear(date);
const month = pad(getMonth(date) + 1);
const day = pad(getDate(date));
return `fp/${slug}/${year}/${month}/${day}/${vodId}/${filename}`;
}
export function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const hh = hours.toString().padStart(2, '0');
const mm = minutes.toString().padStart(2, '0');
const ss = seconds.toString().padStart(2, '0');
return hours > 0 ? `${hh}:${mm}:${ss}` : `${mm}:${ss}`;
}

View File

@ -1,201 +0,0 @@
// src/utils/funscripts.ts
import { join } from "node:path";
import { writeJson } from "fs-extra";
import { env } from "../config/env";
import { nanoid } from "nanoid";
import { loadDataYaml, loadVideoMetadata, processLabelFiles } from "./vibeui";
import logger from "./logger";
export interface FunscriptAction {
at: number;
pos: number;
}
export interface Funscript {
version: string;
actions: FunscriptAction[];
}
export interface Detection {
startFrame: number;
endFrame: number;
className: string;
}
export interface ClassPositionMap {
[className: string]: number | 'pattern';
}
export type FunscriptType = 'vibrate' | 'thrust';
export const intervalMs = 50; // 20Hz
export const classPositionMap: ClassPositionMap = {
RespondingTo: 5,
ControlledByTipper: 50,
ControlledByTipperHigh: 80,
ControlledByTipperLow: 20,
ControlledByTipperMedium: 50,
ControlledByTipperUltrahigh: 100,
Ring1: 30,
Ring2: 40,
Ring3: 50,
Ring4: 60,
Earthquake: 'pattern',
Fireworks: 'pattern',
Pulse: 'pattern',
Wave: 'pattern',
Pause: 0,
RandomTime: 70,
HighLevel: 80,
LowLevel: 20,
MediumLevel: 50,
UltraHighLevel: 95
};
/**
* Returns deterministic waveform positions for a segment.
* All patterns are preset waveforms; no randomness.
*/
function getPatternPosition(progress: number, className: string, type: FunscriptType): number {
if (type === 'thrust') {
// pick frequency based on class
let cycles = 2; // default = 2 full oscillations over the segment
switch (className) {
case 'LowLevel': cycles = 1; break; // slow
case 'MediumLevel': cycles = 2; break;
case 'HighLevel': cycles = 4; break; // faster
case 'UltraHighLevel': cycles = 8; break; // very fast
}
const raw = 50 + 50 * Math.sin(progress * cycles * 2 * Math.PI);
return Math.max(0, Math.min(100, Math.round(raw)));
}
// vibrate & other pattern classes unchanged
switch (className) {
case 'Pulse':
return Math.round(50 + 50 * Math.sin(progress * 2 * Math.PI));
case 'Wave':
return Math.round(50 + 50 * Math.sin(progress * 2 * Math.PI));
case 'Fireworks':
return Math.round(50 + 50 * Math.sin(progress * 4 * Math.PI));
case 'Earthquake':
return Math.round(50 + 40 * Math.sin(progress * 8 * Math.PI));
default:
return 50;
}
}
/**
* Generates actions for a segment that uses a pattern.
*/
export function generatePatternPositions(
startMs: number,
durationMs: number,
className: string,
type: FunscriptType
): FunscriptAction[] {
const actions: FunscriptAction[] = [];
for (let timeMs = 0; timeMs < durationMs; timeMs += intervalMs) {
const progress = timeMs / durationMs;
const pos = getPatternPosition(progress, className, type);
actions.push({ at: startMs + timeMs, pos });
}
return actions;
}
/**
* Generates actions for the whole video.
*/
export function generateActions(
totalDurationMs: number,
fps: number,
detectionSegments: Detection[],
classPositionMap: ClassPositionMap,
type: FunscriptType
): FunscriptAction[] {
const actionMap = new Map<number, number>();
for (let timeMs = 0; timeMs <= totalDurationMs; timeMs += intervalMs) {
const frameIndex = Math.floor((timeMs / 1000) * fps);
let pos: number | undefined = 50; // default mid-point
for (const segment of detectionSegments) {
if (frameIndex >= segment.startFrame && frameIndex <= segment.endFrame) {
const className = segment.className;
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
// will be handled by pattern later
pos = undefined;
} else if (typeof classPositionMap[className] === 'number') {
pos = classPositionMap[className];
}
break;
}
}
if (pos !== undefined) actionMap.set(timeMs, pos);
}
// Overlay pattern-based positions
for (const segment of detectionSegments) {
const className = segment.className;
if (type === 'thrust' || classPositionMap[className] === 'pattern') {
const startMs = Math.floor((segment.startFrame / fps) * 1000);
const durationMs = Math.floor(((segment.endFrame - segment.startFrame + 1) / fps) * 1000);
const patternActions = generatePatternPositions(startMs, durationMs, className, type);
for (const action of patternActions) {
actionMap.set(action.at, action.pos);
}
}
}
const actions: FunscriptAction[] = Array.from(actionMap.entries())
.map(([at, pos]) => ({ at, pos }))
.sort((a, b) => a.at - b.at);
return actions;
}
/**
* Write JSON funscript file.
*/
export async function writeFunscript(outputPath: string, actions: FunscriptAction[]) {
const funscript: Funscript = { version: '1.0', actions };
await writeJson(outputPath, funscript);
logger.debug(`Funscript generated: ${outputPath} (${actions.length} actions)`);
}
/**
* Build funscript from YOLO detections + video metadata.
*/
export async function buildFunscript(
predictionOutput: string,
videoPath: string,
type: FunscriptType
): Promise<string> {
if (!type) throw new Error("buildFunscript requires type: 'vibrate' or 'thrust'");
const labelDir = join(predictionOutput, 'labels');
const outputPath = join(process.env.CACHE_ROOT ?? '/tmp', `${nanoid()}.funscript`);
try {
const data = await loadDataYaml(join(env.VIBEUI_DIR, 'data.yaml'));
const { fps, totalFrames } = await loadVideoMetadata(videoPath);
const detectionSegments = await processLabelFiles(labelDir, data);
const totalDurationMs = Math.floor((totalFrames / fps) * 1000);
const actions = generateActions(totalDurationMs, fps, detectionSegments, classPositionMap, type);
await writeFunscript(outputPath, actions);
return outputPath;
} catch (error) {
logger.error(`Error generating Funscript: ${error instanceof Error ? error.message : 'Unknown error'}`);
throw error;
}
}

View File

@ -1,22 +0,0 @@
import env from "../../.config/env";
import PocketBase from "pocketbase";
let pbClient: PocketBase | null = null;
export async function getPocketBaseClient(): Promise<PocketBase> {
if (pbClient) {
// Return the already initialized client
return pbClient;
}
const pb = new PocketBase(env.POCKETBASE_URL);
// Authenticate once
await pb.collection("_superusers").authWithPassword(
env.POCKETBASE_USERNAME,
env.POCKETBASE_PASSWORD
);
pbClient = pb;
return pbClient;
}

View File

@ -1,302 +0,0 @@
import { nanoid } from "nanoid";
import { join } from "node:path";
import { readFile, writeFile, readdir, mkdir } from 'node:fs/promises';
import yaml from 'js-yaml';
import spawn from 'nano-spawn';
import env from '../../env';
import sharp from 'sharp';
import { Tensor, InferenceSession } from "onnxruntime-web";
interface Detection {
startFrame: number;
endFrame: number;
className: string;
}
interface DataYaml {
path: string;
train: string;
val: string;
names: Record<string, string>;
}
interface DetectionOutput {
classIndex: number;
confidence: number;
bbox: [number, number, number, number]; // e.g. [x, y, width, height]
}
export async function extractFrames(videoPath: string, framesDir: string) {
await mkdir(framesDir, { recursive: true });
await spawn('ffmpeg', [
'-i', videoPath,
join(framesDir, '%06d.jpg'),
]);
}
export async function preprocessImage(imagePath: string): Promise<Tensor> {
// This is highly dependent on your model input
// Typically:
// - Read image
// - Resize to model input size (e.g., 640x640)
// - Normalize pixel values (e.g., divide by 255)
// - Change shape to [batch_size, channels, height, width]
// For example, using sharp:
const inputWidth = 640;
const inputHeight = 640;
const imageBuffer = await sharp(imagePath)
.resize(inputWidth, inputHeight)
.removeAlpha()
.raw()
.toBuffer();
if (!imageBuffer) throw new Error(`failed to get imageBuffer from ${imagePath}`);
// Convert to Float32Array, normalize, and change to CHW format
const floatArray = new Float32Array(inputWidth * inputHeight * 3);
for (let i = 0; i < inputWidth * inputHeight; i++) {
// RGB channels
for (let c = 0; c < 3; c++) {
const blah = imageBuffer[i * 3 + c]
if (!blah) throw new Error(`imageBuffer[i * 3 + c] is undefined`);
floatArray[c * inputWidth * inputHeight + i] = blah / 255;
}
}
// Create tensor of shape [1, 3, 640, 640]
return new Tensor('float32', floatArray, [1, 3, inputHeight, inputWidth]);
}
export async function writeLabels(outputPath: string, detectionsByFrame: Map<number, DetectionOutput[]>, classNames: Record<string, string>) {
// Write labels in YOLO txt format per frame:
// class x_center y_center width height confidence
// normalized to [0, 1] relative to image size
await mkdir(outputPath, { recursive: true });
const labelDir = join(outputPath, 'labels');
await mkdir(labelDir, { recursive: true });
for (const [frameIndex, detections] of detectionsByFrame.entries()) {
const lines: string[] = [];
for (const det of detections) {
const className = classNames[det.classIndex.toString()] ?? 'unknown';
// Convert bbox to normalized YOLO format (x_center, y_center, width, height) in [0..1]
// Assuming input image size 640x640
const [x, y, w, h] = det.bbox;
const x_center = (x + w / 2) / 640;
const y_center = (y + h / 2) / 640;
const w_norm = w / 640;
const h_norm = h / 640;
lines.push(`${det.classIndex} ${x_center.toFixed(6)} ${y_center.toFixed(6)} ${w_norm.toFixed(6)} ${h_norm.toFixed(6)} ${det.confidence.toFixed(6)}`);
}
await writeFile(join(labelDir, `${frameIndex}.txt`), lines.join('\n'));
}
}
/**
* Loads and parses a YOLO-style data.yaml file.
*
* - Reads the YAML file from the given path.
* - Parses the content into a `DataYaml` object.
*
* @param yamlPath - Path to the data.yaml file.
* @returns Parsed contents as a `DataYaml` object.
* @throws If the file cannot be read or parsed.
*/
export async function loadDataYaml(yamlPath: string): Promise<DataYaml> {
const yamlContent = await readFile(yamlPath, 'utf8');
return yaml.load(yamlContent) as DataYaml;
}
/**
* Runs YOLO inference on the given video file using the configured model.
*
* - Prepares the Python environment and loads the YOLO model.
* - Generates a unique output directory for the results.
* - Executes YOLO with flags to save text labels and confidence scores (but not images).
*
* @param videoFilePath - Path to the input video file to analyze.
* @returns Path to the output directory containing the prediction results.
*/
export async function inference(videoFilePath: string): Promise<string> {
const modelPath = join(env.VIBEUI_DIR, 'vibeui.pt')
// Generate a unique name based on video name + UUID
const uniqueName = nanoid()
const customProjectDir = 'vibeui/runs' // or any custom folder
const outputPath = join(env.APP_DIR, customProjectDir, uniqueName)
await spawn('yolo', [
'predict',
`model=${modelPath}`,
`source=${videoFilePath}`,
'save=False',
'save_txt=True',
'save_conf=True',
`project=${customProjectDir}`,
`name=${uniqueName}`,
], {
cwd: env.APP_DIR,
stdio: 'inherit',
})
return outputPath // contains labels/ folder and predictions
}
/**
* Extracts video metadata (FPS and frame count) using ffprobe.
*
* - Spawns an ffprobe subprocess to analyze the video stream.
* - Retrieves the frame rate (`r_frame_rate`) and total frame count (`nb_read_frames`).
* - Parses and returns both values as numbers.
*
* @param videoPath - Path to the video file to analyze.
* @returns An object containing the video's frames per second (`fps`) and total number of frames (`frames`).
* @throws If ffprobe fails or returns malformed output.
*/
export async function ffprobe(videoPath: string): Promise<{ fps: number; frames: number }> {
const { stdout } = await spawn('ffprobe', [
'-v', 'error',
'-select_streams', 'v:0',
'-count_frames',
'-show_entries', 'stream=nb_read_frames,r_frame_rate',
'-of', 'default=nokey=1:noprint_wrappers=1',
videoPath,
])
const [frameRateStr, frameCountStr] = stdout.trim().split('\n')
if (!frameRateStr) throw new Error('fucking frameRateStr was undef');
const [num, denom] = frameRateStr.trim().split('/').map(Number)
if (!num) throw new Error('num of frameRateStr was undefined');
if (!denom) throw new Error('num of frameRateStr was undefined');
const fps = num / denom
if (!frameCountStr) throw new Error('frameCountStr undef');
const frames = parseInt(frameCountStr.trim(), 10)
return { fps, frames }
}
/**
* Loads basic metadata from a video file using ffprobe.
*
* - Retrieves the video's frame rate (fps) and total frame count.
* - Logs the extracted metadata
*
* @param videoPath - Path to the video file to analyze.
* @returns An object containing `fps` and `totalFrames`.
* @throws If metadata extraction fails.
*/
export async function loadVideoMetadata(videoPath: string) {
const { fps, frames: totalFrames } = await ffprobe(videoPath);
// job.log(`Video metadata: fps=${fps}, frames=${totalFrames}`);
return { fps, totalFrames };
}
export async function processLabelFiles(labelDir: string, data: DataYaml): Promise<Detection[]> {
const labelFiles = (await readdir(labelDir)).filter(file => file.endsWith('.txt'));
job.log(`[processLabelFiles] Found label files: ${labelFiles.length}`);
if (labelFiles.length === 0) job.log(`⚠️⚠️⚠️ no label files found! this should normally NOT happen unless the video contained no lovense overlay. ⚠️⚠️⚠️`);
const detections: Map<number, Detection[]> = new Map();
const names = data.names;
for (const file of labelFiles) {
const match = file.match(/(\d+)\.txt$/);
if (!match) {
job.log(`[processLabelFiles] Skipping invalid filename: ${file}`);
continue;
}
if (!match[1]) {
throw new Error('match[1] was falsy');
}
const frameIndex = parseInt(match[1], 10);
if (isNaN(frameIndex)) {
job.log(`[processLabelFiles] Skipping invalid frame index: ${file}`);
continue;
}
const content = await readFile(join(labelDir, file), 'utf8');
const lines = content.trim().split('\n');
const frameDetections: Detection[] = [];
let maxConfidence = 0;
let selectedClassIndex = -1;
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 6) continue;
if (!parts[0]) throw new Error('parts[0] undef');
if (!parts[5]) throw new Error('parts[5] undef');
const classIndex = parseInt(parts[0], 10);
const confidence = parseFloat(parts[5]);
if (isNaN(classIndex) || isNaN(confidence)) continue;
if (confidence >= 0.7 && confidence > maxConfidence) {
maxConfidence = confidence;
selectedClassIndex = classIndex;
}
}
if (maxConfidence > 0 && selectedClassIndex !== -1) {
const className = names[selectedClassIndex.toString()];
if (className) {
job.log(`[processLabelFiles] Frame ${frameIndex}: detected class "${className}" with confidence ${maxConfidence}`);
frameDetections.push({ startFrame: frameIndex, endFrame: frameIndex, className });
} else {
job.log(`[processLabelFiles] Frame ${frameIndex}: class index ${selectedClassIndex} has no name`);
}
}
if (frameDetections.length > 0) {
detections.set(frameIndex, frameDetections);
}
}
// Merge overlapping detections into continuous segments
const detectionSegments: Detection[] = [];
let currentDetection: Detection | null = null;
for (const [frameIndex, frameDetections] of [...detections.entries()].sort((a, b) => a[0] - b[0])) {
for (const detection of frameDetections) {
if (!currentDetection || currentDetection.className !== detection.className) {
if (currentDetection) detectionSegments.push(currentDetection);
currentDetection = { ...detection, endFrame: frameIndex };
} else {
currentDetection.endFrame = frameIndex;
}
}
}
if (currentDetection) detectionSegments.push(currentDetection);
job.log(`[processLabelFiles] Total detection segments: ${detectionSegments.length}`);
for (const segment of detectionSegments) {
job.log(` - Class "${segment.className}": frames ${segment.startFrame}${segment.endFrame}`);
}
return detectionSegments;
}

View File

@ -1,29 +0,0 @@
// generalWorker
import { Worker } from 'bullmq';
import { connection } from '../../.config/bullmq.config.ts';
import { presignMuxAssets } from '../processors/presignMuxAssets.ts';
import { copyV2ThumbToV3 } from '../processors/copyV2ThumbToV3.ts';
new Worker(
'generalQueue',
async (job) => {
console.log('generalWorker. we got a job on the generalQueue.', job.data, job.name);
switch (job.name) {
case 'presignMuxAssets':
return await presignMuxAssets(job);
case 'copyV2ThumbToV3':
return await copyV2ThumbToV3(job);
// case 'analyzeAudio':
// return await analyzeAudio(job);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
},
{ connection }
);
console.log('generalWorker is running...');

View File

@ -1,20 +0,0 @@
// gpuWorker
import { Worker } from 'bullmq';
import { connection } from '../../.config/bullmq.config.ts';
new Worker(
'gpuQueue',
async (job) => {
console.log('gpuWorker. we got a job on the gpuQueue.', job.data, job.name);
switch (job.name) {
// @todo implement
default:
throw new Error(`gpuWorker Unknown job name: ${job.name}`);
}
},
{ connection }
);
console.log('gpuWorker is running...');

View File

@ -1,22 +0,0 @@
// highPriorityWorker runs all jobs
import { Worker } from 'bullmq';
import { connection } from '../../.config/bullmq.config.ts';
import { syncronizePatreon } from '../processors/syncronizePatreon.ts'
new Worker(
'highPriorityQueue',
async (job) => {
console.log('highPriorityWorker. we got a job on the highPriorityQueue.', job.data, job.name);
switch (job.name) {
case 'syncronizePatreon':
return await syncronizePatreon(job);
default:
throw new Error(`Unknown job name: ${job.name}`);
}
},
{ connection }
);
console.log('highPriorityWorker is running...');

View File

@ -1,9 +0,0 @@
#!/bin/bash
while true
do
now=$(date)
me=$(whoami)
echo "User $me at $now"
sleep 10
done

View File

@ -1,8 +0,0 @@
#!/bin/bash
loginctl enable-linger
sudo cp worker.service /etc/systemd/user/worker.service
systemctl --user daemon-reload
systemctl --user restart worker
journalctl --user -u worker -ef
systemctl --user status worker

View File

@ -1,15 +0,0 @@
[Unit]
Description=Futureporn worker
After=network.target
[Service]
Type=simple
Restart=always
RestartSec=5
ExecStart=/home/cj/Documents/futureporn-monorepo/services/worker/entrypoint.sh
WorkingDirectory=/home/cj/Documents/futureporn-monorepo/services/worker
EnvironmentFile=/home/cj/Documents/futureporn-monorepo/services/worker/.env.production.local
Restart=on-failure
[Install]
WantedBy=default.target