Compare commits
2 Commits
6caf2dbcc3
...
665b7ea924
| Author | SHA1 | Date | |
|---|---|---|---|
| 665b7ea924 | |||
| 965a8f0d6e |
130
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,130 @@
|
||||
{
|
||||
"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": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "futureporn",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.0",
|
||||
"private": true,
|
||||
"description": "Dedication to the preservation of lewdtuber history",
|
||||
"license": "Unlicense",
|
||||
|
||||
@ -10,22 +10,10 @@
|
||||
* @see https://github.com/pocketbase/pocketbase/discussions/5995
|
||||
*
|
||||
*/
|
||||
onFileDownloadRequest((e) => {
|
||||
onFileDownloadRequest((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()
|
||||
|
||||
// console.log('event', JSON.stringify(event))
|
||||
// e.app
|
||||
// e.collection
|
||||
// e.record
|
||||
@ -36,72 +24,89 @@ onFileDownloadRequest((e) => {
|
||||
const securityKey = process.env?.BUNNY_TOKEN_KEY;
|
||||
const baseUrl = process.env?.BUNNY_ZONE_URL;
|
||||
|
||||
console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`)
|
||||
// console.log(`securityKey=${securityKey}, baseUrl=${baseUrl}`)
|
||||
|
||||
if (!securityKey) {
|
||||
console.error('BUNNY_TOKEN_KEY was missing from env');
|
||||
return e.next();
|
||||
return event.next();
|
||||
}
|
||||
if (!baseUrl) {
|
||||
console.error('BUNNY_ZONE_URL was missing from env');
|
||||
return e.next();
|
||||
return event.next();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Generates a BunnyCDN-style signed URL using directory tokens.
|
||||
*
|
||||
* We sign URLs to make hotlinking difficult
|
||||
* @see https://support.bunny.net/hc/en-us/articles/360016055099-How-to-sign-URLs-for-BunnyCDN-Token-Authentication
|
||||
* @see https://github.com/pocketbase/pocketbase/discussions/5983#discussioncomment-11426659 // HMAC in pocketbase
|
||||
* @see https://github.com/pocketbase/pocketbase/discussions/6772 // base64 encode the hex
|
||||
* Generate a signed BunnyCDN URL.
|
||||
* @param {string} securityKey - Your BunnyCDN security token
|
||||
* @param {string} baseUrl - The base URL (protocol + host)
|
||||
* @param {string} path - Path to the file (starting with /)
|
||||
* @param {string} rawQuery - Raw query string, e.g., "width=500&quality=5"
|
||||
* @param {number} expires - Unix timestamp for expiration
|
||||
*/
|
||||
function signUrl(securityKey, baseUrl, path, expires) {
|
||||
function signUrlCool(securityKey, baseUrl, path, rawQuery = "", expires) {
|
||||
|
||||
if (!path.startsWith('/')) path = '/' + path;
|
||||
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));
|
||||
|
||||
const hashableBase = securityKey + path + expires;
|
||||
|
||||
// Generate and encode the token
|
||||
const tokenH = $security.sha256(hashableBase);
|
||||
|
||||
const token = Buffer.from(tokenH, "hex")
|
||||
.toString("base64")
|
||||
.replace(/\n/g, "") // Remove newlines
|
||||
.replace(/\+/g, "-") // Replace + with -
|
||||
.replace(/\//g, "_") // Replace / with _
|
||||
.replace(/=/g, ""); // Remove =
|
||||
|
||||
|
||||
// Generate the URL
|
||||
const signedUrl = baseUrl + path + '?token=' + token + '&expires=' + expires;
|
||||
|
||||
return signedUrl;
|
||||
if (params.length) {
|
||||
parameterData = params.map(([k, v]) => `${k}=${v}`).join("&");
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`record: ${JSON.stringify(e.record)}`)
|
||||
console.log(`collection: ${JSON.stringify(e.collection)}`)
|
||||
console.log(`app: ${JSON.stringify(e.app)}`)
|
||||
console.log(`fileField: ${JSON.stringify(e.fileField)}`)
|
||||
console.log(`servedPath: ${JSON.stringify(e.servedPath)}`)
|
||||
console.log(`servedName: ${JSON.stringify(e.servedName)}`)
|
||||
// Build hashable base
|
||||
const hashableBase = securityKey + path + expires + parameterData;
|
||||
// console.log(`hashableBase`, hashableBase)
|
||||
|
||||
// Compute token using your $security.sha256 workflow
|
||||
const tokenH = $security.sha256(hashableBase);
|
||||
const token = Buffer.from(tokenH, "hex")
|
||||
.toString("base64")
|
||||
.replace(/\n/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=/g, "");
|
||||
|
||||
// Build final signed URL
|
||||
let tokenUrl = baseUrl + path + "?token=" + token;
|
||||
if (parameterData) tokenUrl += "&" + parameterData;
|
||||
tokenUrl += "&expires=" + expires;
|
||||
|
||||
return tokenUrl;
|
||||
}
|
||||
|
||||
|
||||
|
||||
const rawQuery = event.requestEvent.request.url.rawQuery;
|
||||
|
||||
// console.log(`record: ${JSON.stringify(event.record)}`)
|
||||
// // console.log(`collection: ${JSON.stringify(event.collection)}`)
|
||||
// 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
|
||||
// Then serve a 302 redirect instead of serving the file proxied thru PB
|
||||
|
||||
const path = e.servedPath;
|
||||
const path = event.servedPath;
|
||||
const expires = Math.round(Date.now() / 1000) + 3600;
|
||||
const signedUrl = signUrl(securityKey, baseUrl, path, expires);
|
||||
console.log(`signedUrl=${signedUrl}`);
|
||||
const signedUrl = signUrlCool(securityKey, baseUrl, path, rawQuery, expires);
|
||||
// console.log(`rawQUery`, rawQuery, 'path', path);
|
||||
// console.log(`signedUrl=${signedUrl}`);
|
||||
|
||||
// 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.
|
||||
// HOWEVER, this redirect slows down image loading because it now takes 2 requests per image.
|
||||
e.redirect(302, signedUrl);
|
||||
event.redirect(302, signedUrl);
|
||||
|
||||
e.next()
|
||||
event.next()
|
||||
})
|
||||
@ -5,7 +5,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>
|
||||
<%=meta('title') || '~~~~~' %>
|
||||
<%= meta('title') %>
|
||||
</title>
|
||||
<meta name="description" content="<%=meta('description') || 'aaaaa'%>" />
|
||||
<meta property="og:title" content="<%=meta('title') || 'Futureporn.net'%>" />
|
||||
@ -71,8 +71,11 @@
|
||||
<footer class="footer mt-5">
|
||||
<div class="content has-text-centered">
|
||||
<p>
|
||||
<strong>Futureporn <%= data.version %></strong> made with love by <a href="https://t.co/I8p0oH0AAB">@CJ_Clippy</a>.
|
||||
<strong>Futureporn <%= meta('version') %></strong> made with love by <a href="https://t.co/I8p0oH0AAB">@CJ_Clippy</a>.
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
|
||||
@ -1,31 +1,18 @@
|
||||
/** @type {import('pocketpages').PageDataLoaderFunc} */
|
||||
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)
|
||||
|
||||
/**
|
||||
*
|
||||
* This middleware handles setting data.user for auth purposes
|
||||
*/
|
||||
|
||||
module.exports = function ({ meta, redirect, request, auth }) {
|
||||
|
||||
let user;
|
||||
|
||||
if (auth) {
|
||||
console.log('request.auth is present id:', auth.get('id'))
|
||||
user = $app.findFirstRecordByData('users', 'id', auth.id);
|
||||
}
|
||||
|
||||
return { user, version: require(`../../../package.json`).version }
|
||||
return { user }
|
||||
}
|
||||
|
||||
// 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()
|
||||
// }
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<div class="mt-5">
|
||||
<h3 class="title is-3">Account Settings</h3>
|
||||
|
||||
<label class="checkbox">
|
||||
<label class="checkbox" data-signals='{"publicUsername": <%= auth.get("publicUsername") ? "true" : "false" %>}'>
|
||||
<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>
|
||||
</label>
|
||||
|
||||
14
services/pocketbase/pb_hooks/pages/(site)/feed/index.ejs
Normal file
@ -0,0 +1,14 @@
|
||||
<%#
|
||||
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>
|
||||
@ -1,9 +1,3 @@
|
||||
<% 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">
|
||||
<li class="mb-2">
|
||||
<a class="button" href="/vt">
|
||||
|
||||
@ -8,7 +8,7 @@ module.exports = function (api) {
|
||||
// Find all users who have both a publicUsername and are patrons
|
||||
const patronsRaw = $app.findRecordsByFilter(
|
||||
'users',
|
||||
'publicUsername != "" && patron = true',
|
||||
'publicUsername = true && patron = true',
|
||||
'-created', // sort (optional)
|
||||
50, // limit
|
||||
0, // offset
|
||||
|
||||
@ -63,9 +63,13 @@
|
||||
<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')) { %>
|
||||
<p><b>Notes:</b></p>
|
||||
<pre class="p-2"><%= data.vod?.get('notes') %></pre>
|
||||
<div class="p-2 level"><%- data.vod?.get('notes') %></div>
|
||||
<% } %>
|
||||
|
||||
<% if (data.vod?.get('thumbnail')) { %>
|
||||
|
||||
@ -8,7 +8,6 @@ module.exports = {
|
||||
'pocketpages-plugin-realtime',
|
||||
'pocketpages-plugin-auth',
|
||||
'pocketpages-plugin-js-sdk',
|
||||
'pocketpages-plugin-micro-dash',
|
||||
'../../../src/plugins/patreon'
|
||||
'pocketpages-plugin-micro-dash'
|
||||
],
|
||||
}
|
||||
|
||||
15
services/pocketbase/pb_hooks/pages/+middleware.js
Normal file
@ -0,0 +1,15 @@
|
||||
/** @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)
|
||||
|
||||
}
|
||||
@ -29,7 +29,7 @@
|
||||
<td style="width: 160px;">
|
||||
<% if (vod.thumbnail) { %>
|
||||
<figure class="image is-3by2">
|
||||
<img src="/api/files/<%= vod.collectionId %>/<%= vod.id %>/<%= vod.thumbnail %>" alt="Thumbnail" style="width: 120px; border-radius: 8px;">
|
||||
<img src="/api/files/<%= vod.collectionId %>/<%= vod.id %>/<%= vod.thumbnail %>?quality=5&width=12" alt="Thumbnail" style="width: 120px; border-radius: 8px;">
|
||||
</figure>
|
||||
<% } else { %>
|
||||
<span>No thumbnail</span>
|
||||
|
||||
@ -22,10 +22,11 @@
|
||||
return response.html(401, "Auth required")
|
||||
}
|
||||
|
||||
console.log('signals as follows')
|
||||
const signals = datastar.readSignals(request, {})
|
||||
console.log('signals as followssssssss', JSON.stringify(signals));
|
||||
|
||||
user.set('publicUsername', signals.publicUsername);
|
||||
$app.save(user);
|
||||
|
||||
// Determine the publicUsername status
|
||||
const publicStatus = user.get('publicUsername') ?
|
||||
|
||||
@ -4,9 +4,8 @@
|
||||
module.exports = function (api) {
|
||||
const { params, response } = api;
|
||||
try {
|
||||
const vods = $app.findRecordsByFilter('vods', null, '-streamDate');
|
||||
const vods = $app.findRecordsByFilter('vods', null, '-streamDate', 25);
|
||||
$app.expandRecords(vods, ["vtubers"], null);
|
||||
// vods.expandedAll("vtubers");
|
||||
return { vods };
|
||||
|
||||
} catch (e) {
|
||||
|
||||
@ -12,18 +12,24 @@ const feed = {
|
||||
home_page_url: "https://futureporn.net",
|
||||
feed_url: "https://futureporn.net/vods/feed.json",
|
||||
description: meta('description'),
|
||||
icon: "https://futureporn.net/images/futureporn-icon.png",
|
||||
icon: "https://futureporn.net/assets/logo.png",
|
||||
author: {
|
||||
name: "CJ_Clippy",
|
||||
url: "https://futureporn.net"
|
||||
},
|
||||
items: data.vods.map(vod => ({
|
||||
content_html: "",
|
||||
content_html: `VOD ${vod.get('id')} featuring ${vod.get('expand').vtubers.map((vt) => vt.get('displayName')).join(', ')} streamed on ${vod.get('streamDate')}`,
|
||||
notes: vod.get('notes'),
|
||||
url: `https://futureporn.net/vods/${vod.id}`,
|
||||
title: vod.title,
|
||||
summary: vod.notes || vod.title,
|
||||
image: vod.thumbnail,
|
||||
date_modified: vod.updated
|
||||
announceUrl: vod.get('announceUrl'),
|
||||
id: vod.get('id'),
|
||||
ipfsCid: vod.get('ipfsCid'),
|
||||
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) %>
|
||||
29
services/pocketbase/pb_migrations/1762955236_updated_vods.js
Normal file
@ -0,0 +1,29 @@
|
||||
/// <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)
|
||||
})
|
||||
BIN
services/pocketbase/pb_public/apple-touch-icon-114x114.png
Normal file
|
After Width: | Height: | Size: 675 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-120x120.png
Normal file
|
After Width: | Height: | Size: 625 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-144x144.png
Normal file
|
After Width: | Height: | Size: 454 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-152x152.png
Normal file
|
After Width: | Height: | Size: 928 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-180x180.png
Normal file
|
After Width: | Height: | Size: 835 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-57x57.png
Normal file
|
After Width: | Height: | Size: 516 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-72x72.png
Normal file
|
After Width: | Height: | Size: 550 B |
BIN
services/pocketbase/pb_public/apple-touch-icon-76x76.png
Normal file
|
After Width: | Height: | Size: 540 B |
BIN
services/pocketbase/pb_public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 516 B |
BIN
services/pocketbase/pb_public/logo.png
Normal file
|
After Width: | Height: | Size: 248 B |
@ -1,3 +1,5 @@
|
||||
// 2025-11-07-import-thumbnails.js
|
||||
|
||||
import PocketBase from 'pocketbase';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
|
||||
@ -0,0 +1,86 @@
|
||||
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);
|
||||
});
|
||||
@ -0,0 +1,149 @@
|
||||
// 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()
|
||||
@ -1,43 +1,29 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import PocketBase from 'pocketbase';
|
||||
import { spawn } from 'node:child_process';
|
||||
import util from 'node:util';
|
||||
const spawnAsync = util.promisify(spawn);
|
||||
import spawn from 'nano-spawn';
|
||||
|
||||
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');
|
||||
async function main() {
|
||||
|
||||
const pb = new PocketBase('http://localhost:8090');
|
||||
|
||||
await pb
|
||||
.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', [
|
||||
await spawn('rsync', [
|
||||
'-avz',
|
||||
'--exclude=pb_data',
|
||||
'--exclude=*.local',
|
||||
'-e',
|
||||
'ssh',
|
||||
'.',
|
||||
'root@fp:/home/pb/pb'
|
||||
]);
|
||||
|
||||
// @see https://pocketbase.io/docs/api-settings/#update-settings
|
||||
|
||||
// put it back to dev app url
|
||||
await pb.settings.update({
|
||||
meta: {
|
||||
appName: 'Futureporn',
|
||||
appUrl: process.env.APPURL,
|
||||
},
|
||||
], {
|
||||
stdio: 'inherit'
|
||||
});
|
||||
|
||||
// fix ownership
|
||||
await spawn('ssh', ['fp', 'chown', '-R', 'pb:pb', '/home/pb/pb']);
|
||||
|
||||
// restart pocketbase
|
||||
await spawn('systemctl', ['--host=fp', 'restart', 'pocketbase.service']);
|
||||
|
||||
}
|
||||
|
||||
main();
|
||||
8
services/worker/.config/bullmq.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
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: ''
|
||||
};
|
||||
64
services/worker/.config/env.ts
Normal file
@ -0,0 +1,64 @@
|
||||
|
||||
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;
|
||||
29
services/worker/.config/tsconfig.json
Normal file
@ -0,0 +1,29 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
34
services/worker/.gitignore
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
# 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
|
||||
15
services/worker/README.md
Normal file
@ -0,0 +1,15 @@
|
||||
# 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.
|
||||
234
services/worker/bun.lock
Normal file
@ -0,0 +1,234 @@
|
||||
{
|
||||
"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=="],
|
||||
}
|
||||
}
|
||||
3
services/worker/entrypoint.sh
Executable file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
/home/cj/.nvm/versions/node/v22.18.0/bin/node --import tsx ./src/index.ts
|
||||
24
services/worker/err.md
Normal file
@ -0,0 +1,24 @@
|
||||
```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
|
||||
^[
|
||||
```
|
||||
199
services/worker/index.ts.old
Normal file
@ -0,0 +1,199 @@
|
||||
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 */
|
||||
});
|
||||
5242
services/worker/package-lock.json
generated
Normal file
37
services/worker/package.json
Normal file
@ -0,0 +1,37 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
79
services/worker/src/index.ts
Normal file
@ -0,0 +1,79 @@
|
||||
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));
|
||||
133
services/worker/src/processors/analyzeAudio.ts
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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,
|
||||
})
|
||||
}
|
||||
5
services/worker/src/processors/childTask.ts
Normal file
@ -0,0 +1,5 @@
|
||||
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 };
|
||||
}
|
||||
11
services/worker/src/processors/cleanup.ts
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
||||
156
services/worker/src/processors/copyV1S3ToV2.ts
Normal file
@ -0,0 +1,156 @@
|
||||
// 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;
|
||||
181
services/worker/src/processors/createFunscript.ts
Normal file
@ -0,0 +1,181 @@
|
||||
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);
|
||||
};
|
||||
|
||||
|
||||
220
services/worker/src/processors/createHlsPlaylist.ts
Normal file
@ -0,0 +1,220 @@
|
||||
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
|
||||
|
||||
}
|
||||
100
services/worker/src/processors/createIpfsCid.ts
Normal file
@ -0,0 +1,100 @@
|
||||
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 }
|
||||
});
|
||||
|
||||
}
|
||||
114
services/worker/src/processors/createStoryboard.ts
Normal file
@ -0,0 +1,114 @@
|
||||
|
||||
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,
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
206
services/worker/src/processors/createTorrent.ts
Normal file
@ -0,0 +1,206 @@
|
||||
/**
|
||||
*
|
||||
* # 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.`)
|
||||
|
||||
|
||||
}
|
||||
86
services/worker/src/processors/createTranscription.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/** 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 }
|
||||
});
|
||||
|
||||
}
|
||||
126
services/worker/src/processors/createVideoThumbnail.ts
Normal file
@ -0,0 +1,126 @@
|
||||
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
|
||||
|
||||
|
||||
}
|
||||
30
services/worker/src/processors/findWork.ts
Normal file
@ -0,0 +1,30 @@
|
||||
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;
|
||||
70
services/worker/src/processors/generateVideoChecksum.ts
Normal file
@ -0,0 +1,70 @@
|
||||
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;
|
||||
235
services/worker/src/processors/getSourceVideo.ts
Normal file
@ -0,0 +1,235 @@
|
||||
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;
|
||||
133
services/worker/src/processors/getSourceVideoMetadata.ts
Normal file
@ -0,0 +1,133 @@
|
||||
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;
|
||||
14
services/worker/src/processors/parentTask.ts
Normal file
@ -0,0 +1,14 @@
|
||||
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}`);
|
||||
}
|
||||
74
services/worker/src/processors/presignMuxAssets.ts
Normal file
@ -0,0 +1,74 @@
|
||||
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 };
|
||||
}
|
||||
82
services/worker/src/processors/scheduleVodProcessing.ts
Normal file
@ -0,0 +1,82 @@
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
||||
207
services/worker/src/processors/syncronizePatreon.ts
Normal file
@ -0,0 +1,207 @@
|
||||
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 };
|
||||
}
|
||||
27
services/worker/src/queues/generalQueue.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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
|
||||
// },
|
||||
// );
|
||||
|
||||
17
services/worker/src/queues/gpuQueue.ts
Normal file
@ -0,0 +1,17 @@
|
||||
|
||||
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: {}
|
||||
},
|
||||
)
|
||||
|
||||
14
services/worker/src/queues/highPriorityQueue.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { Queue } from "bullmq";
|
||||
export const highPriorityQueue = new Queue('highPriorityQueue');
|
||||
|
||||
await highPriorityQueue.upsertJobScheduler(
|
||||
'sync-patreon-recurring-job',
|
||||
{
|
||||
every: 45000
|
||||
},
|
||||
{
|
||||
name: 'syncronizePatreon',
|
||||
data: {},
|
||||
opts: {}
|
||||
},
|
||||
)
|
||||
4
services/worker/src/queues/parentQueue.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { Queue } from 'bullmq';
|
||||
import { connection } from '../../.config/bullmq.config.ts';
|
||||
|
||||
export const parentMq = new Queue('parentMq', { connection });
|
||||
7
services/worker/src/util/b2.ts
Normal file
@ -0,0 +1,7 @@
|
||||
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;
|
||||
}
|
||||
54
services/worker/src/util/formatters.ts
Normal file
@ -0,0 +1,54 @@
|
||||
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}`;
|
||||
}
|
||||
201
services/worker/src/util/funscripts.ts
Normal file
@ -0,0 +1,201 @@
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
22
services/worker/src/util/pocketbase.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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;
|
||||
}
|
||||
302
services/worker/src/util/vibeui.ts
Normal file
@ -0,0 +1,302 @@
|
||||
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;
|
||||
}
|
||||
|
||||
29
services/worker/src/workers/generalWorker.ts
Normal file
@ -0,0 +1,29 @@
|
||||
// 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...');
|
||||
20
services/worker/src/workers/gpuWorker.ts
Normal file
@ -0,0 +1,20 @@
|
||||
// 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...');
|
||||
22
services/worker/src/workers/highPriorityWorker.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// 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...');
|
||||
9
services/worker/systemd/test.sh
Executable file
@ -0,0 +1,9 @@
|
||||
#!/bin/bash
|
||||
|
||||
while true
|
||||
do
|
||||
now=$(date)
|
||||
me=$(whoami)
|
||||
echo "User $me at $now"
|
||||
sleep 10
|
||||
done
|
||||
8
services/worker/systemd/up.sh
Executable file
@ -0,0 +1,8 @@
|
||||
#!/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
|
||||
15
services/worker/systemd/worker.service
Normal file
@ -0,0 +1,15 @@
|
||||
[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
|
||||