add vtuber rss
This commit is contained in:
parent
31efd1ff51
commit
afc9e2d1c8
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Vod" ADD COLUMN "magetLink" TEXT;
|
@ -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;
|
@ -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?
|
||||||
|
@ -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: {
|
||||||
|
1
services/our/src/assets/svg/magnet.svg
Normal file
1
services/our/src/assets/svg/magnet.svg
Normal 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 |
1
services/our/src/assets/svg/patreon.svg
Normal file
1
services/our/src/assets/svg/patreon.svg
Normal 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 |
1
services/our/src/assets/svg/rss.svg
Normal file
1
services/our/src/assets/svg/rss.svg
Normal 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 |
@ -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);
|
||||||
|
3
services/our/src/plugins/README.md
Normal file
3
services/our/src/plugins/README.md
Normal 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
|
@ -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');
|
||||||
|
@ -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' })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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' });
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@ -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' });
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
}
|
154
services/our/src/tasks/createTorrent.ts
Normal file
154
services/our/src/tasks/createTorrent.ts
Normal 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.`)
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -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" },
|
||||||
|
@ -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) {
|
||||||
|
@ -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));
|
||||||
|
@ -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);
|
||||||
}
|
}
|
||||||
|
16
services/our/src/views/feed.hbs
Normal file
16
services/our/src/views/feed.hbs
Normal 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>
|
2
services/our/src/views/layouts/xml.hbs
Normal file
2
services/our/src/views/layouts/xml.hbs
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
{{{body}}}
|
@ -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}}
|
||||||
|
@ -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.
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
{{#> main}}
|
{{#> main}}
|
||||||
|
|
||||||
<header class="container">
|
<header class="container">
|
||||||
{{> navbar}}
|
{{> navbar}}
|
||||||
</header>
|
</header>
|
||||||
|
@ -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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user