add vtuber rss
Some checks failed
ci / build (push) Failing after 2s
ci / Tests & Checks (push) Failing after 1s

This commit is contained in:
CJ_Clippy 2025-08-11 20:51:21 -08:00
parent 31efd1ff51
commit afc9e2d1c8
26 changed files with 340 additions and 52 deletions

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Vod" ADD COLUMN "magetLink" TEXT;

View File

@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `magetLink` on the `Vod` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Vod" DROP COLUMN "magetLink",
ADD COLUMN "magnetLink" TEXT;

View File

@ -80,6 +80,7 @@ model Vod {
asrVttKey String? asrVttKey String?
slvttSheetKeys Json? slvttSheetKeys Json?
slvttVTTKey String? slvttVTTKey String?
magnetLink String?
status VodStatus @default(pending) status VodStatus @default(pending)
sha256sum String? sha256sum String?

View File

@ -168,7 +168,6 @@ export function buildApp() {
handlebars: Handlebars, handlebars: Handlebars,
}, },
templates: join(__dirname, '..', 'src', 'views'), templates: join(__dirname, '..', 'src', 'views'),
layout: 'layouts/main',
viewExt: 'hbs', viewExt: 'hbs',
options: { options: {
partials: { partials: {

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><g fill="none"><path fill="#d3d3d3" d="M11 23v6.06c0 .52-.42.94-.94.94H3.94c-.52 0-.94-.42-.94-.94V23l4.028-2.152zm18 0v6.06c0 .52-.42.94-.94.94h-6.12c-.52 0-.94-.42-.94-.94V23l3.99-2.152z"/><path fill="#f8312f" d="M11 23v-7.94c0-2.75 2.2-5.04 4.95-5.06c2.78-.03 5.05 2.23 5.05 5v8h8v-8c0-7.18-5.82-13-13-13S3 7.82 3 15v8z"/></g></svg>

After

Width:  |  Height:  |  Size: 395 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482c-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46c3.765.047 4.326-4.804 6.068-7.141c1.24-1.662 2.836-2.132 4.801-2.618c3.376-.836 5.678-3.501 5.673-7.031"/></svg>

After

Width:  |  Height:  |  Size: 379 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><defs><linearGradient id="SVGKULYdcTK" x1="30.06" x2="225.94" y1="30.06" y2="225.94" gradientTransform="matrix(.11 0 0 .11 2 2)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e3702d"/><stop offset=".11" stop-color="#ea7d31"/><stop offset=".35" stop-color="#f69537"/><stop offset=".5" stop-color="#fb9e3a"/><stop offset=".7" stop-color="#ea7c31"/><stop offset=".89" stop-color="#de642b"/><stop offset="1" stop-color="#d95b29"/></linearGradient></defs><rect width="28" height="28" x="2" y="2" fill="#cc5d15" rx="6.01" ry="6.01"/><rect width="26.91" height="26.91" x="2.54" y="2.54" fill="#f49c52" rx="5.47" ry="5.47"/><rect width="25.82" height="25.82" x="3.1" y="3.1" fill="url(#SVGKULYdcTK)" rx="5.14" ry="5.14"/><path fill="#fff" d="M6.82 6.16v3.83a15.31 15.31 0 0 1 15.3 15.3h3.83A19.14 19.14 0 0 0 6.81 6.17zm0 6.45v3.72a8.97 8.97 0 0 1 8.96 8.97h3.72A12.69 12.69 0 0 0 6.81 12.6zm2.62 7.44a2.63 2.63 0 0 0-2.63 2.62a2.63 2.63 0 0 0 2.63 2.63a2.63 2.63 0 0 0 2.63-2.63a2.63 2.63 0 0 0-2.63-2.63z"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -31,6 +31,9 @@ const EnvSchema = z.object({
APP_DIR: z.string().default('/app'), APP_DIR: z.string().default('/app'),
WHISPER_DIR: z.string(), WHISPER_DIR: z.string(),
LOG_LEVEL: z.string().default('info'), LOG_LEVEL: z.string().default('info'),
SEEDBOX_SFTP_URL: z.string(),
SEEDBOX_SFTP_USERNAME: z.string(),
SEEDBOX_SFTP_PASSWORD: z.string(),
}); });
const parsed = EnvSchema.safeParse(process.env); const parsed = EnvSchema.safeParse(process.env);

View File

@ -0,0 +1,3 @@
Everything in fastify is a plugin. Routes are plugins too.
@see https://fastify.dev/docs/latest/Guides/Plugins-Guide/#register

View File

@ -9,6 +9,7 @@ import { constants } from '../config/constants'
import { readFile } from 'fs/promises' import { readFile } from 'fs/promises'
import { extractBasePath } from '../utils/filesystem' import { extractBasePath } from '../utils/filesystem'
import { dirname } from 'node:path' import { dirname } from 'node:path'
import logger from '../utils/logger'
const prisma = new PrismaClient().$extends(withAccelerate()) const prisma = new PrismaClient().$extends(withAccelerate())
const s3 = getS3Client() const s3 = getS3Client()
@ -61,7 +62,7 @@ function rewriteMp4ReferencesWithSignedUrls(
signFn: (path: string) => string signFn: (path: string) => string
): string { ): string {
console.log(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`) logger.debug(`rewriteMp4ReferencesWithSignedUrls called with ${playlistContent} ${cdnBasePath} ${signFn}`)
const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash const cleanBase = cdnBasePath.replace(/^\/|\/$/g, '') // remove leading/trailing slash
@ -101,8 +102,8 @@ export default async function registerHlsRoute(app: FastifyInstance) {
select: { hlsPlaylist: true }, select: { hlsPlaylist: true },
}) })
console.log(`vod as follows`) logger.debug(`vod as follows`)
console.log(vod) logger.debug(vod)
if (!vod.hlsPlaylist) return reply.status(404).send('') if (!vod.hlsPlaylist) return reply.status(404).send('')
const s3Key = `${dirname(vod.hlsPlaylist)}/${manifest}` const s3Key = `${dirname(vod.hlsPlaylist)}/${manifest}`
@ -119,7 +120,7 @@ export default async function registerHlsRoute(app: FastifyInstance) {
// Otherwise, rewrite .mp4 references with signed URLs // Otherwise, rewrite .mp4 references with signed URLs
const tokenPath = `/${dirname(vod.hlsPlaylist)}/` const tokenPath = `/${dirname(vod.hlsPlaylist)}/`
console.log(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`) logger.debug(`tokenPath=${tokenPath} hlsPlaylist=${vod.hlsPlaylist}`)
if (!tokenPath.startsWith('/')) { if (!tokenPath.startsWith('/')) {
throw new Error('tokenPath did not start with a forward slash'); throw new Error('tokenPath did not start with a forward slash');

View File

@ -20,7 +20,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
return reply.viewAsync("profile.hbs", { return reply.viewAsync("profile.hbs", {
user, user,
site: constants.site site: constants.site
}); }, { layout: 'layouts/main.hbs' });
}) })
fastify.get('/', async function (request, reply) { fastify.get('/', async function (request, reply) {
@ -62,7 +62,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
patreonChannels: [], patreonChannels: [],
authPath, authPath,
site: constants.site site: constants.site
}); }, { layout: 'layouts/main.hbs' });
} }
// Safe to query database // Safe to query database
@ -94,7 +94,7 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
// streams, // streams,
// tags, // tags,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
}); });
fastify.get('/health', function (request, reply) { fastify.get('/health', function (request, reply) {
@ -114,6 +114,6 @@ export default async function indexRoutes(fastify: FastifyInstance): Promise<voi
NODE_ENV, NODE_ENV,
authPath, authPath,
site: constants.site, site: constants.site,
}) }, { layout: 'layouts/main.hbs' })
}) })
} }

View File

@ -345,7 +345,7 @@ export default async function streamsRoutes(
} }
}); });
return reply.view("obs.ejs", { obsToken, cdnOrigin }); return reply.view("obs.ejs", { obsToken, cdnOrigin }, { layout: 'layouts/main.hbs' });
}); });

View File

@ -129,7 +129,7 @@ export default async function uploadsRoutes(
user, user,
info, info,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
}); });
fastify.get('/uploads/:uploadId', async function (request, reply) { fastify.get('/uploads/:uploadId', async function (request, reply) {
@ -165,7 +165,7 @@ export default async function uploadsRoutes(
upload, upload,
user, user,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
}); });
fastify.get('/upload', async function (request, reply) { fastify.get('/upload', async function (request, reply) {
@ -203,7 +203,7 @@ export default async function uploadsRoutes(
vtubers, vtubers,
user, user,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
}) })
@ -247,7 +247,7 @@ export default async function uploadsRoutes(
vtubers, vtubers,
user, user,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
} }
if (!body.streamDate) { if (!body.streamDate) {
@ -256,7 +256,7 @@ export default async function uploadsRoutes(
vtubers, vtubers,
user, user,
site site
}); }, { layout: 'layouts/main.hbs' });
} }
const vtuberIds = [body.vtuberIds].flat() const vtuberIds = [body.vtuberIds].flat()
@ -266,7 +266,7 @@ export default async function uploadsRoutes(
vtubers, vtubers,
user, user,
site site
}); }, { layout: 'layouts/main.hbs' });
} }
@ -602,7 +602,7 @@ export default async function uploadsRoutes(
user, user,
site: constants.site, site: constants.site,
message: `Saved ${updatedUpload.updatedAt}`, message: `Saved ${updatedUpload.updatedAt}`,
}); }, { layout: 'layouts/main.hbs' });
} catch (err) { } catch (err) {
request.log.error(err); request.log.error(err);

View File

@ -69,7 +69,7 @@ export default async function vodsRoutes(
user, user,
vods, vods,
site: constants.site, site: constants.site,
}); }, { layout: 'layouts/main.hbs' });
}); });
@ -110,7 +110,7 @@ export default async function vodsRoutes(
vod, vod,
site: constants.site, site: constants.site,
user, user,
}); }, { layout: 'layouts/main.hbs' });
}); });
fastify.post('/vods/:id/process', async function (request, reply) { fastify.post('/vods/:id/process', async function (request, reply) {
@ -147,7 +147,7 @@ export default async function vodsRoutes(
site: constants.site, site: constants.site,
user, user,
message: 'Successfully scheduled vod processing.' message: 'Successfully scheduled vod processing.'
}); }, { layout: 'layouts/main.hbs' });
}) })

View File

@ -6,6 +6,7 @@ import { withAccelerate } from "@prisma/extension-accelerate"
import { isUnprivilegedUser } from "../utils/privs"; import { isUnprivilegedUser } from "../utils/privs";
import { slug } from "../utils/formatters"; import { slug } from "../utils/formatters";
import type { UploadResult } from '../types/index' import type { UploadResult } from '../types/index'
import { env } from "../config/env";
const prisma = new PrismaClient().$extends(withAccelerate()) const prisma = new PrismaClient().$extends(withAccelerate())
const hexColorRegex = /^#([0-9a-fA-F]{6})$/; const hexColorRegex = /^#([0-9a-fA-F]{6})$/;
@ -37,7 +38,7 @@ export default async function vtubersRoutes(
user, user,
vtubers, vtubers,
site: constants.site site: constants.site
}); }, { layout: 'layouts/main.hbs' });
}); });
@ -60,7 +61,7 @@ export default async function vtubersRoutes(
user, user,
info: reply.flash('info'), info: reply.flash('info'),
error: reply.flash('error') error: reply.flash('error')
}) }, { layout: 'layouts/main.hbs' })
}) })
fastify.post('/vtubers/create', async function (request, reply) { fastify.post('/vtubers/create', async function (request, reply) {
@ -169,7 +170,7 @@ export default async function vtubersRoutes(
vtubers, vtubers,
user, user,
site site
}); }, { layout: 'layouts/main.hbs' });
} }
@ -284,7 +285,61 @@ export default async function vtubersRoutes(
vtuber, vtuber,
site: constants.site, site: constants.site,
user, user,
}, { layout: 'layouts/main.hbs' });
});
fastify.get('/vtubers/:idOrSlug/rss', async function (request, reply) {
const { idOrSlug } = request.params as { idOrSlug: string };
if (!idOrSlug) {
return reply.status(400).send({ error: 'Invalid VTuber identifier' });
}
// Determine if it's a CUID (starts with "c" and length of 24)
const isCuid = /^c[a-z0-9]{23}$/i.test(idOrSlug);
const vtuber = await prisma.vtuber.findFirst({
where: isCuid
? { id: idOrSlug }
: {
OR: [
{ slug: idOrSlug },
{ id: idOrSlug }, // fallback if someone pastes a cuid as a slug
],
},
include: {
vods: {
orderBy: {
streamDate: 'desc',
}
}
}
}); });
if (!vtuber) {
return reply.status(404).send({ error: 'VTuber not found' });
}
const items = vtuber.vods.map(vod => ({
title: `Stream on ${vod.streamDate.toDateString()}`,
link: `${env.ORIGIN}/vod/${vod.id}`,
guid: `${env.ORIGIN}/vod/${vod.id}`,
pubDate: vod.streamDate.toUTCString(),
description: vod.notes || 'No description available',
}));
const title = 'future.porn - ' + vtuber.displayName || vtuber.slug
return reply
.type('application/rss+xml')
.view('/feed.hbs', {
title,
description: vtuber.description || title,
link: `${env.ORIGIN}/vtuber/${vtuber.slug || vtuber.id}`,
items,
}, { layout: 'layouts/xml.hbs' });
}); });
} }

View File

@ -0,0 +1,154 @@
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 { preparePython } from "../utils/python";
import logger from "../utils/logger";
import { basename, join } from "node:path";
import SftpClient from 'ssh2-sftp-client';
const prisma = new PrismaClient().$extends(withAccelerate());
interface Payload {
vodId: string;
}
// async function createTorrent(payload: any, helpers: Helpers) {
// helpers.logger.debug(`createTorrent`)
// if (!inputFilePath) {
// throw new Error("inputFilePath is missing");
// }
// await preparePython()
// const outputFilePath = inputFilePath.replace(/\.[^/.]+$/, '') + '-thumb.png';
// const spawn = await getNanoSpawn();
// helpers.logger.debug('result as follows')
// helpers.logger.debug(JSON.stringify(result, null, 2))
// helpers.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 createTorrent(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 magnet link.')
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 torrentfile
// torrentfile create
// --magnet
// --prog 0
// --out ./test-fixture.torrent
// --announce udp://tracker.futureporn.net/
// --source https://futureporn.net/
// --comment https://futureporn.net/
// --web-seed https://futureporn-b2.b-cdn.net/test-fixture.ts
// --meta-version 3
// ~/Downloads/test-fixture.ts
const torrentOutputFile = join(env.CACHE_ROOT, `${nanoid()}.torrent`);
const result = await spawn('./venv/bin/torrentfile', [
'create',
'--magnet',
'--prog', '0',
'--meta-version', '2',
'--comment', 'https://future.porn',
'--source', `https://future.porn/vod/${vodId}`,
'--out', torrentOutputFile,
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}`);
// upload torrent file to seedbox sftp
// Actually I don't think we need this, because our seedbox can use the RSS feed and get informed about torrents that way
// let sftp = new SftpClient();
// const torrentBasename = basename(torrentOutputFile);
// const parsed = new URL(env.SEEDBOX_SFTP_URL);
// logger.debug(`url=${env.SEEDBOX_SFTP_URL} hostname=${parsed.hostname} port=${parsed.port} username=${env.SEEDBOX_SFTP_USERNAME} password=${env.SEEDBOX_SFTP_PASSWORD}`);
// await sftp.connect({
// host: parsed.hostname,
// port: parsed.port,
// username: env.SEEDBOX_SFTP_USERNAME,
// password: env.SEEDBOX_SFTP_PASSWORD
// })
// const remoteFilePath = join(parsed.pathname, torrentBasename)
// const data = await sftp.list(parsed.pathname);
// logger.debug(`the data=${JSON.stringify(data)}`);
// logger.debug(`uploading ${torrentOutputFile} to ${remoteFilePath}`)
// await sftp.put(torrentOutputFile, remoteFilePath);
logger.debug(`updating vod record`);
await prisma.vod.update({
where: { id: vodId },
data: { magnetLink }
});
logger.debug(`all done.`)
}

View File

@ -1,8 +1,6 @@
import type { Task, Helpers } from "graphile-worker"; import type { Task, Helpers } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma"; import { PrismaClient } from "../../generated/prisma";
import { access, stat, writeFile } from "node:fs/promises"; import { access, stat, writeFile } from "node:fs/promises";
import { createReadStream, createWriteStream } from "node:fs";
import path from "node:path";
import { join } from "node:path"; import { join } from "node:path";
import { getOrDownloadAsset } from "../utils/cache"; import { getOrDownloadAsset } from "../utils/cache";
import { env } from "../config/env"; import { env } from "../config/env";
@ -11,6 +9,8 @@ import { uploadFile } from "../utils/s3";
import { S3Client } from "@aws-sdk/client-s3"; import { S3Client } from "@aws-sdk/client-s3";
import { VodSegment } from "../types"; import { VodSegment } from "../types";
import { getNanoSpawn } from "../utils/nanoSpawn"; import { getNanoSpawn } from "../utils/nanoSpawn";
import { parseISO, getYear, getMonth, getDate } from 'date-fns';
import logger from "../utils/logger";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
const client = new S3Client({ const client = new S3Client({
@ -46,7 +46,7 @@ async function validateSegments(segments: VodSegment[], helpers: Helpers) {
throw new Error("No VOD segments provided"); throw new Error("No VOD segments provided");
} }
helpers.logger.info(`Processing ${segments.length} video segments`); logger.info(`Processing ${segments.length} video segments`);
} }
async function downloadSegments( async function downloadSegments(
@ -57,14 +57,14 @@ async function downloadSegments(
for (const [index, segment] of segmentKeys.entries()) { for (const [index, segment] of segmentKeys.entries()) {
try { try {
helpers.logger.debug(`Downloading segment ${index + 1}/${segmentKeys.length}`); logger.debug(`Downloading segment ${index + 1}/${segmentKeys.length}`);
const path = await getOrDownloadAsset(client, env.S3_BUCKET, segment.key); const path = await getOrDownloadAsset(client, env.S3_BUCKET, segment.key);
downloadedPaths.push(path); downloadedPaths.push(path);
// Verify the segment exists and is accessible // Verify the segment exists and is accessible
await access(path); await access(path);
const size = await getFileSize(path); const size = await getFileSize(path);
helpers.logger.debug(`Segment ${index + 1} size: ${(size / 1024 / 1024).toFixed(2)}MB`); logger.debug(`Segment ${index + 1} size: ${(size / 1024 / 1024).toFixed(2)}MB`);
} catch (error) { } catch (error) {
throw new Error(`Failed to download segment ${segment.key}: ${error instanceof Error ? error.message : String(error)}`); throw new Error(`Failed to download segment ${segment.key}: ${error instanceof Error ? error.message : String(error)}`);
} }
@ -89,7 +89,7 @@ async function concatenateSegments(
concatSpec: FFmpegConcatSpec, concatSpec: FFmpegConcatSpec,
helpers: Helpers helpers: Helpers
): Promise<string> { ): Promise<string> {
helpers.logger.info(`Concatenating ${concatSpec.files.length} segments`); logger.info(`Concatenating ${concatSpec.files.length} segments`);
try { try {
const spawn = await getNanoSpawn(); const spawn = await getNanoSpawn();
@ -105,13 +105,13 @@ async function concatenateSegments(
cwd: env.CACHE_ROOT, cwd: env.CACHE_ROOT,
}); });
helpers.logger.debug(`FFmpeg output: ${proc.stdout}`); logger.debug(`FFmpeg output: ${proc.stdout}`);
helpers.logger.debug(`FFmpeg stderr: ${proc.stderr}`); logger.debug(`FFmpeg stderr: ${proc.stderr}`);
// Verify output file // Verify output file
await access(concatSpec.outputPath); await access(concatSpec.outputPath);
const outputSize = await getFileSize(concatSpec.outputPath); const outputSize = await getFileSize(concatSpec.outputPath);
helpers.logger.info(`Concatenated file size: ${(outputSize / 1024 / 1024).toFixed(2)}MB`); logger.info(`Concatenated file size: ${(outputSize / 1024 / 1024).toFixed(2)}MB`);
return concatSpec.outputPath; return concatSpec.outputPath;
} catch (error) { } catch (error) {
@ -124,7 +124,7 @@ async function updateVodWithSourceVideo(
sourcePath: string, sourcePath: string,
helpers: Helpers helpers: Helpers
): Promise<void> { ): Promise<void> {
helpers.logger.info(`Updating VOD ${vodId} with source video ${sourcePath}`); logger.info(`Updating VOD ${vodId} with source video ${sourcePath}`);
await prisma.vod.update({ await prisma.vod.update({
where: { id: vodId }, where: { id: vodId },
@ -158,7 +158,7 @@ const getSourceVideo: Task = async (payload: unknown, helpers) => {
assertPayload(payload); assertPayload(payload);
const { vodId } = payload; const { vodId } = payload;
helpers.logger.info(`Processing source video for VOD ${vodId}`); logger.info(`Processing source video for VOD ${vodId}`);
try { try {
// Get VOD info from database // Get VOD info from database
@ -168,12 +168,14 @@ const getSourceVideo: Task = async (payload: unknown, helpers) => {
sourceVideo: true, sourceVideo: true,
segmentKeys: true, segmentKeys: true,
status: true, status: true,
vtubers: true,
streamDate: true,
}, },
}); });
// Skip if already processed // Skip if already processed
if (vod.sourceVideo) { if (vod.sourceVideo) {
helpers.logger.debug(`VOD ${vodId} already has a source video`); logger.debug(`VOD ${vodId} already has a source video`);
return; return;
} }
@ -196,7 +198,7 @@ const getSourceVideo: Task = async (payload: unknown, helpers) => {
if (downloadedPaths.length === 1) { if (downloadedPaths.length === 1) {
// Single segment - no concatenation needed // Single segment - no concatenation needed
sourceVideoPath = downloadedPaths[0]; sourceVideoPath = downloadedPaths[0];
helpers.logger.info(`Using single segment as source video`); logger.info(`Using single segment as source video`);
} else { } else {
// Multiple segments - concatenate // Multiple segments - concatenate
const concatSpec: FFmpegConcatSpec = { const concatSpec: FFmpegConcatSpec = {
@ -211,14 +213,17 @@ const getSourceVideo: Task = async (payload: unknown, helpers) => {
// upload the concatenated video // upload the concatenated video
const key = await uploadFile(client, env.S3_BUCKET, `source/${nanoid()}.mp4`, sourceVideoPath, 'video/mp4'); 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 // Update database with source video path
await updateVodWithSourceVideo(vodId, key, helpers); await updateVodWithSourceVideo(vodId, key, helpers);
helpers.logger.info(`Successfully processed source video for VOD ${vodId}`); logger.info(`Successfully processed source video for VOD ${vodId}`);
} catch (error) { } catch (error) {
helpers.logger.error(`Failed to process source video for VOD ${vodId}: ${error instanceof Error ? error.message : String(error)}`); logger.error(`Failed to process source video for VOD ${vodId}: ${error instanceof Error ? error.message : String(error)}`);
// await prisma.vod.update({ // await prisma.vod.update({
// where: { id: vodId }, // where: { id: vodId },
// data: { status: "failed" }, // data: { status: "failed" },

View File

@ -1,7 +1,6 @@
import type { Helpers, Task, Job } from "graphile-worker"; import type { Task, Job } from "graphile-worker";
import { PrismaClient } from "../../generated/prisma"; import { PrismaClient } from "../../generated/prisma";
import { withAccelerate } from "@prisma/extension-accelerate"; import { withAccelerate } from "@prisma/extension-accelerate";
import { addMinutes, addSeconds } from 'date-fns';
interface Payload { interface Payload {
@ -47,6 +46,7 @@ const scheduleVodProcessing: Task = async (payload: unknown, helpers) => {
if (!vod.funscript) jobs.push(helpers.addJob("createFunscript", { vodId })); if (!vod.funscript) jobs.push(helpers.addJob("createFunscript", { vodId }));
if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId })); if (!vod.asrVttKey) jobs.push(helpers.addJob("createTranscription", { vodId }));
if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId })); if (!vod.slvttVTTKey) jobs.push(helpers.addJob("createStoryboard", { vodId }));
if (!vod.magnetLink) jobs.push(helpers.addJob("createTorrent", { vodId }));
const changes = jobs.length; const changes = jobs.length;
if (changes > 0) { if (changes > 0) {

View File

@ -1,3 +1,5 @@
import logger from "./logger";
type UserWithRoles = { roles: { name: string }[] }; type UserWithRoles = { roles: { name: string }[] };
@ -20,9 +22,11 @@ export function isUnprivilegedUser(user: { roles: { name: string }[] }): boolean
export function hasRole(...args: any[]) { export function hasRole(...args: any[]) {
const options = args.pop(); // handlebars injects this const options = args.pop(); // handlebars options object
const roles: string[] = args.slice(0, -1); const user = args.pop(); // user is second-to-last arg
const user = args[args.length - 1]; const roles: string[] = args; // everything else is role names
logger.trace(`roles=${roles.join(',')} user=${JSON.stringify(user)}`);
if (!user?.roles) return false; if (!user?.roles) return false;
return user.roles.some((r: any) => roles.includes(r.name)); return user.roles.some((r: any) => roles.includes(r.name));

View File

@ -23,7 +23,7 @@ export async function preparePython() {
await spawn(pythonCmd, ["-m", "venv", venvPath], { await spawn(pythonCmd, ["-m", "venv", venvPath], {
cwd: env.APP_DIR, cwd: env.APP_DIR,
}); });
console.log("Python venv created."); console.log("Python venv created.");
} else { } else {
console.log("Using existing Python venv."); console.log("Using existing Python venv.");
} }
@ -34,15 +34,15 @@ export async function preparePython() {
await spawn(pipCmd, ["install", "-r", "requirements.txt"], { await spawn(pipCmd, ["install", "-r", "requirements.txt"], {
cwd: env.APP_DIR, cwd: env.APP_DIR,
}); });
console.log("requirements.txt installed."); console.log("requirements.txt installed.");
// 4. Confirm vcsi CLI binary exists // 4. Confirm vcsi CLI binary exists
const vcsiBinary = join(venvBin, "vcsi"); const vcsiBinary = join(venvBin, "vcsi");
if (!existsSync(vcsiBinary)) { if (!existsSync(vcsiBinary)) {
console.error("vcsi binary not found in venv after installing requirements."); console.error("vcsi binary not found in venv after installing requirements.");
console.error("Make sure 'vcsi' is listed in requirements.txt and that it installs a CLI."); console.error("Make sure 'vcsi' is listed in requirements.txt and that it installs a CLI.");
throw new Error("vcsi installation failed or did not expose CLI."); throw new Error("vcsi installation failed or did not expose CLI.");
} }
console.log("vcsi CLI is available at", vcsiBinary); console.log("vcsi CLI is available at", vcsiBinary);
} }

View File

@ -0,0 +1,16 @@
<rss version="2.0">
<channel>
<title>{{title}}</title>
<link>{{link}}</link>
<description>{{description}}</description>
{{#each items}}
<item>
<title>{{this.title}}</title>
<link>{{this.link}}</link>
<guid isPermaLink="true">{{this.guid}}</guid>
<pubDate>{{this.pubDate}}</pubDate>
<description><![CDATA[{{this.description}}]]></description>
</item>
{{/each}}
</channel>
</rss>

View File

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="UTF-8" ?>
{{{body}}}

View File

@ -8,9 +8,10 @@
<section id="perks"> <section id="perks">
<h2>Perks</h2> <h2>Perks</h2>
<p>future.porn is free to use, but to keep the site running, we need your help! In return, we offer extra perks <p>future.porn is free to use, but to keep the site running we need your help! In return, we offer extra perks
to supporters.</p> to supporters.</p>
<table> <table>
<thead> <thead>
<tr> <tr>
@ -63,6 +64,8 @@
<td>✔️</td> <td>✔️</td>
<td>✔️</td> <td>✔️</td>
</tr> </tr>
{{!--
@todo add these things
<tr> <tr>
<td><abbr title="Closed Captions">CC</abbr> Search</td> <td><abbr title="Closed Captions">CC</abbr> Search</td>
<td></td> <td></td>
@ -87,9 +90,15 @@
<td></td> <td></td>
<td>✔️</td> <td>✔️</td>
</tr> </tr>
--}}
</tbody> </tbody>
</table> </table>
<article>
<p>Become a patron at <a target="_blank" href="https://patreon.com/CJ_Clippy">patreon.com/CJ_Clippy</a></p>
</article>
</section> </section>
{{> footer}} {{> footer}}

View File

@ -119,7 +119,7 @@
<h2>Downloads</h2> <h2>Downloads</h2>
<h3>Raw Recorded File Segments</h3> <h3>Raw Segments</h3>
{{#if vod.segmentKeys}} {{#if vod.segmentKeys}}
<ul> <ul>
{{#each vod.segmentKeys}} {{#each vod.segmentKeys}}
@ -135,13 +135,24 @@
</article> </article>
{{/if}} {{/if}}
<h3>Concatenated Video</h3> <h3>VOD</h3>
{{#if vod.sourceVideo}} {{#if vod.sourceVideo}}
{{#if (hasRole "supporterTier1" user)}}
<p><a data-source-video="{{getCdnUrl vod.sourceVideo}}" data-file-name="{{basename vod.sourceVideo}}" <p><a data-source-video="{{getCdnUrl vod.sourceVideo}}" data-file-name="{{basename vod.sourceVideo}}"
x-on:click.prevent="download($el.dataset.sourceVideo, $el.dataset.fileName)" x-on:click.prevent="download($el.dataset.sourceVideo, $el.dataset.fileName)"
href="{{getCdnUrl vod.sourceVideo}}" download="{{basename vod.sourceVideo}}" href="{{getCdnUrl vod.sourceVideo}}" download="{{basename vod.sourceVideo}}"
target="_blank">{{icon "download" 24}} Download</a> target="_blank">{{icon "download" 24}} Download</a>
</p> </p>
{{else}}
<p>
<a href="/perks">{{icon "patreon" 24}}</a>
<del>
CDN Download
</del>
</p>
{{/if}}
<p>{{#if vod.sha256sum}}<span><b>sha256sum</b> {{vod.sha256sum}}</span>{{/if}}</p> <p>{{#if vod.sha256sum}}<span><b>sha256sum</b> {{vod.sha256sum}}</span>{{/if}}</p>
{{#if vod.cidv1}} {{#if vod.cidv1}}
<p><b>IPFS cidv1</b> {{vod.cidv1}}</p> <p><b>IPFS cidv1</b> {{vod.cidv1}}</p>
@ -150,6 +161,13 @@
IPFS CID is processing. IPFS CID is processing.
</article> </article>
{{/if}} {{/if}}
{{#if vod.magnetLink}}
<p><a href="{{vod.magnetLink}}">{{icon "magnet" 24}} Magnet Link</a></p>
{{else}}
<article>
Magnet Link is processing.
</article>
{{/if}}
{{else}} {{else}}
<article> <article>
Video Source is processing. Video Source is processing.

View File

@ -1,4 +1,5 @@
{{#> main}} {{#> main}}
<header class="container"> <header class="container">
{{> navbar}} {{> navbar}}
</header> </header>

View File

@ -1,4 +1,6 @@
{{#> main}} {{#> main}}
<link rel="alternate" type="application/rss+xml" href="/vtubers/{{vtuber.slug}}/rss" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.22.0/dist/video-js.min.css"> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/video.js@8.22.0/dist/video-js.min.css">
<header class="container"> <header class="container">
@ -38,7 +40,8 @@
</video> --}} </video> --}}
<h1> <h1>
{{vtuber.displayName}} {{vtuber.displayName}} <a href="/vtubers/{{vtuber.slug}}/rss"
alt="RSS feed for {{vtuber.displayName}}">{{icon "rss" 32}}</a>
</h1> </h1>
<div class="grid"> <div class="grid">