fixing frontend image quirks

This commit is contained in:
CJ_Clippy 2024-06-12 17:38:11 -08:00
parent 1b436de8d8
commit 71f19065d0
33 changed files with 407 additions and 171 deletions

View File

@ -11,6 +11,9 @@ crds:
cert-manager: cert-manager:
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.14.4/cert-manager.yaml
secrets:
./scripts/k8s-secrets.sh
flux: flux:
flux bootstrap git --url="ssh://git@gitea.futureporn.net:2222/futureporn/fp" --branch=main --path="clusters/production" --private-key-file=/home/cj/.ssh/fp-flux flux bootstrap git --url="ssh://git@gitea.futureporn.net:2222/futureporn/fp" --branch=main --path="clusters/production" --private-key-file=/home/cj/.ssh/fp-flux

View File

@ -34,6 +34,16 @@ spec:
value: "{{ .Values.scout.cdnBucketUrl }}" value: "{{ .Values.scout.cdnBucketUrl }}"
- name: STRAPI_URL - name: STRAPI_URL
value: https://strapi.piko.sbtp.xyz value: https://strapi.piko.sbtp.xyz
- name: S3_BUCKET_APPLICATION_KEY
valueFrom:
secretKeyRef:
name: scout
key: s3BucketApplicationKey
- name: S3_BUCKET_KEY_ID
valueFrom:
secretKeyRef:
name: scout
key: s3BucketKeyId
- name: SCOUT_NITTER_ACCESS_KEY - name: SCOUT_NITTER_ACCESS_KEY
valueFrom: valueFrom:
secretKeyRef: secretKeyRef:

View File

@ -1,4 +1,4 @@
import { getAllStreamsForVtuber } from "@/lib/streams"; import { getAllStreamsForVtuber, getStreamCountForVtuber } from "@/lib/streams";
import { getVodsForVtuber } from "@/lib/vods"; import { getVodsForVtuber } from "@/lib/vods";
import { IVtuber } from "@/lib/vtubers"; import { IVtuber } from "@/lib/vtubers";
@ -8,7 +8,7 @@ export interface IArchiveProgressProps {
export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps) { export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps) {
// const vods = await getVodsForVtuber(vtuber.id) // const vods = await getVodsForVtuber(vtuber.id)
// const streams = await getAllStreamsForVtuber(vtuber.id); const streams = await getStreamCountForVtuber(vtuber.id);
// const goodStreams = await getAllStreamsForVtuber(vtuber.id, ['good']); // const goodStreams = await getAllStreamsForVtuber(vtuber.id, ['good']);
// const issueStreams = await getAllStreamsForVtuber(vtuber.id, ['issue']); // const issueStreams = await getAllStreamsForVtuber(vtuber.id, ['issue']);
// const totalStreams = streams.length; // const totalStreams = streams.length;
@ -21,7 +21,11 @@ export default async function ArchiveProgress ({ vtuber }: IArchiveProgressProps
const eligibleStreams = 50 const eligibleStreams = 50
return ( return (
<div> <div>
<p>@todo</p> <pre>
<code>
{JSON.stringify(streams, null, 2)}
</code>
</pre>
<p className="heading">{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)</p> <p className="heading">{eligibleStreams}/{totalStreams} Streams Archived ({completedPercentage}%)</p>
<progress className="progress is-success" value={eligibleStreams} max={totalStreams}>{completedPercentage}%</progress> <progress className="progress is-success" value={eligibleStreams} max={totalStreams}>{completedPercentage}%</progress>
</div> </div>

View File

@ -169,7 +169,8 @@ export default function StreamPage({ stream }: IStreamProps) {
<span className="title is-1"><FontAwesomeIcon icon={icon}></FontAwesomeIcon></span> <span className="title is-1"><FontAwesomeIcon icon={icon}></FontAwesomeIcon></span>
<p className="mt-3">{desc1}</p> <p className="mt-3">{desc1}</p>
<p className="mt-5">{desc2}<br /> <p className="mt-5">{desc2}<br />
<Link href={`/upload?cuid=${stream.attributes.cuid}`}>Upload it here.</Link></p> {/* <Link href={`/upload?cuid=${stream.attributes.cuid}`}>Upload it here.</Link></p> */}
<Link style={{ cursor: 'not-allowed' }} href={`/upload?cuid=${stream.attributes.cuid}`}><i>Uploads coming soon.</i></Link></p>
</div> </div>
</article> </article>
</div> </div>

View File

@ -65,6 +65,7 @@ export default function StreamsTable() {
src={image} src={image}
alt={displayName} alt={displayName}
placeholder="blur" placeholder="blur"
objectFit='contain'
blurDataURL={imageBlur} blurDataURL={imageBlur}
width={32} width={32}
height={32} height={32}

View File

@ -24,6 +24,7 @@ export default async function VTuberCard(vtuber: IVtuber) {
className="is-rounded" className="is-rounded"
src={image} src={image}
alt={displayName} alt={displayName}
objectFit="cover"
placeholder="blur" placeholder="blur"
blurDataURL={imageBlur} blurDataURL={imageBlur}
width={48} width={48}

View File

@ -351,7 +351,25 @@ export async function fetchStreamData({ pageIndex, pageSize }: { pageIndex: numb
return d; return d;
} }
export async function getStreamCountForVtuber(vtuberId: number): Promise<IStreamsResponse> {
if (!vtuberId) throw new Error(`getStreamCountForVtuber requires a vtuberId, but it was undefined.`);
const query = qs.stringify(
{
filters: {
vtuber: {
id: {
$eq: vtuberId
}
}
}
}
)
const res = await fetch(`${strapiUrl}/api/streams?${query}`, fetchStreamsOptions)
const data = await res.json()
console.log('getStreamCountForVtuber')
console.log(JSON.stringify(data, null, 2))
return data.meta.pagination.total
}
export async function getStreamsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25, sortDesc = true): Promise<IStreamsResponse> { export async function getStreamsForVtuber(vtuberId: number, page: number = 1, pageSize: number = 25, sortDesc = true): Promise<IStreamsResponse> {
console.log(`getStreamsForVtuber() with strapiUrl=${strapiUrl}`) console.log(`getStreamsForVtuber() with strapiUrl=${strapiUrl}`)

View File

@ -26,3 +26,21 @@ export interface IMuxAssetResponse {
export interface IMeta { export interface IMeta {
pagination: IPagination; pagination: IPagination;
} }
export interface IPlatformNotification {
id: number;
attributes: {
source: string;
platform: string;
date: string;
date2: string;
vtuber: number;
}
}
export interface IPlatformNotificationResponse {
data: IPlatformNotification;
meta: IMeta;
}

View File

@ -2,9 +2,7 @@
import { IVod } from './vods' import { IVod } from './vods'
import { strapiUrl, siteUrl } from './constants'; import { strapiUrl, siteUrl } from './constants';
import { getSafeDate } from './dates';
import qs from 'qs'; import qs from 'qs';
import { resourceLimits } from 'worker_threads';
import { IMeta } from './types'; import { IMeta } from './types';
@ -43,6 +41,9 @@ export interface IVtuber {
image: string; image: string;
imageBlur?: string; imageBlur?: string;
themeColor: string; themeColor: string;
fanslyId?: string;
chaturbateId?: string;
twitterId?: string;
} }
} }

View File

@ -64,6 +64,7 @@ export default async function Page({ params }: { params: { slug: string } }) {
alt={vtuber.attributes.displayName} alt={vtuber.attributes.displayName}
src={vtuber.attributes.image} src={vtuber.attributes.image}
fill={true} fill={true}
objectFit='cover'
placeholder='blur' placeholder='blur'
blurDataURL={vtuber.attributes.imageBlur} blurDataURL={vtuber.attributes.imageBlur}
/> />

View File

@ -14,6 +14,12 @@ const nextConfig = {
port: '', port: '',
pathname: '/**', pathname: '/**',
}, },
{
protocol: 'https',
hostname: 'fp-dev.b-cdn.net',
port: '',
pathname: '/**',
},
], ],
} }
}; };

View File

@ -2,12 +2,10 @@ import cheerio from 'cheerio'
/** /**
* *
* @param {Object} limiter An instance of node-rate-limiter, see https://github.com/jhurliman/node-rate-limiter
* @param {String} roomUrl example: https://chaturbate.com/projektmelody * @param {String} roomUrl example: https://chaturbate.com/projektmelody
* @returns {Object} initialRoomDossier * @returns {Object} initialRoomDossier
*/ */
export async function getInitialRoomDossier(limiter, roomUrl) { export async function getInitialRoomDossier(roomUrl) {
await limiter.removeTokens(1);
try { try {
const res = await fetch(roomUrl, { const res = await fetch(roomUrl, {
headers: { headers: {

View File

@ -1,13 +1,11 @@
import { describe } from 'mocha' import { describe } from 'mocha'
import { expect } from 'chai'; import { expect } from 'chai';
import { getInitialRoomDossier } from './cb.js' import { getInitialRoomDossier } from './cb.js'
import { RateLimiter } from "limiter";
describe('cb', function () { describe('cb', function () {
let limiter = new RateLimiter({ tokensPerInterval: 10, interval: "minute" })
describe('getInitialRoomDossier', function () { describe('getInitialRoomDossier', function () {
it('should return json', async function () { it('should return json', async function () {
const dossier = await getInitialRoomDossier(limiter, 'https://chaturbate.com/projektmelody') const dossier = await getInitialRoomDossier('https://chaturbate.com/projektmelody')
expect(dossier).to.have.property('wschat_host') expect(dossier).to.have.property('wschat_host')
}) })
}) })

View File

@ -12,12 +12,11 @@ const normalize = (url) => {
const fromUsername = (username) => `https://fansly.com/${username}` const fromUsername = (username) => `https://fansly.com/${username}`
const image = async function image (limiter, fanslyUserId) { const image = async function image (fanslyUserId) {
if (!limiter) throw new Error(`first arg passed to fansly.data.image must be a node-rate-limiter instance`); if (!fanslyUserId) throw new Error(`first arg passed to fansly.data.image must be a {string} fanslyUserId`);
if (!fanslyUserId) throw new Error(`second arg passed to fansly.data.image must be a {string} fanslyUserId`);
const url = `https://api.fansly.com/api/v1/account/${fanslyUserId}/avatar` const url = `https://api.fansly.com/api/v1/account/${fanslyUserId}/avatar`
const filePath = getTmpFile('avatar.jpg') const filePath = getTmpFile('avatar.jpg')
return download({ filePath, limiter, url }) return download({ filePath, url })
} }
const url = { const url = {

View File

@ -4,10 +4,6 @@ import EventEmitter from 'node:events';
import 'dotenv/config'; import 'dotenv/config';
import { simpleParser } from 'mailparser'; import { simpleParser } from 'mailparser';
// pinned to v2.0.1 due to https://github.com/jhurliman/node-rate-limiter/issues/80
import * as $limiter from 'limiter';
const { RateLimiter } = $limiter
if (!process.env.SCOUT_IMAP_SERVER) throw new Error('SCOUT_IMAP_SERVER is missing from env'); if (!process.env.SCOUT_IMAP_SERVER) throw new Error('SCOUT_IMAP_SERVER is missing from env');
@ -15,8 +11,6 @@ if (!process.env.SCOUT_IMAP_PORT) throw new Error('SCOUT_IMAP_PORT is missing fr
if (!process.env.SCOUT_IMAP_USERNAME) throw new Error('SCOUT_IMAP_USERNAME is missing from env'); if (!process.env.SCOUT_IMAP_USERNAME) throw new Error('SCOUT_IMAP_USERNAME is missing from env');
if (!process.env.SCOUT_IMAP_PASSWORD) throw new Error('SCOUT_IMAP_PASSWORD is missing from env'); if (!process.env.SCOUT_IMAP_PASSWORD) throw new Error('SCOUT_IMAP_PASSWORD is missing from env');
const limiter = new RateLimiter({ tokensPerInterval: 1, interval: 3000 });
// https://stackoverflow.com/a/49428486/1004931 // https://stackoverflow.com/a/49428486/1004931
function streamToString(stream) { function streamToString(stream) {
const chunks = []; const chunks = [];
@ -37,7 +31,6 @@ export class Email extends EventEmitter {
} }
async archiveMessage(uid) { async archiveMessage(uid) {
await limiter.removeTokens(1);
await this.client.messageDelete(uid, { uid: true }) await this.client.messageDelete(uid, { uid: true })
} }

View File

@ -25,32 +25,29 @@ async function handleMessage({ email, msg }: { email: Email, msg: FetchMessageOb
// console.log(' ✏️ checking e-mail') // console.log(' ✏️ checking e-mail')
const { isMatch, url, platform, channel, displayName, date, userId, avatar }: NotificationData = (await checkEmail(body) ) const { isMatch, url, platform, channel, displayName, date, userId, avatar }: NotificationData = (await checkEmail(body) )
if (isMatch) { if (isMatch) {
const wfId = `process-email-${createId()}` const wfId = `process-email-${createId()}`
// console.log(` ✏️ [DRY] starting Temporal workflow ${wfId} @todo actually start temporal workflow`) // console.log(` ✏️ [DRY] starting Temporal workflow ${wfId} @todo actually start temporal workflow`)
// await signalRealtime({ url, platform, channel, displayName, date, userId, avatar }) // await signalRealtime({ url, platform, channel, displayName, date, userId, avatar })
// @todo invoke a Temporal workflow here // @todo invoke a Temporal workflow here
console.log(' ✏️✏️ starting Temporal workflow')
const handle = await client.workflow.start(processEmail, { const handle = await client.workflow.start(processEmail, {
workflowId: wfId, workflowId: wfId,
taskQueue: 'scout', taskQueue: 'scout',
args: [{ url, platform, channel, displayName, date, userId, avatar }] args: [{ url, platform, channel, displayName, date, userId, avatar }]
}); });
// const handle = client.getHandle(workflowId); // const handle = client.getHandle(workflowId);
const result = await handle.result(); const result = await handle.result()
console.log(`result of the workflow is as follows`) console.log(`result of the workflow is as follows`)
console.log(result) console.log(result)
throw new Error('!todo we are stopping after just one (for now) @todo')
// console.log(' ✏️✏️ creating stream entry in db')
// await createStreamInDb({ source: 'email', platform, channel, date, url, userId, avatar })
} }
// console.log(' ✏️ archiving e-mail') console.log(' ✏️ archiving e-mail')
// await email.archiveMessage(msg.uid) await email.archiveMessage(msg.uid)
} catch (e) { } catch (e) {
// console.error('error encoutered') // console.error('error encoutered')
console.error(` An error was encountered while handling the following e-mail message.\n${JSON.stringify(msg, null, 2)}\nError as follows.`) console.error(`An error was encountered while handling the following e-mail message.\n${JSON.stringify(msg, null, 2)}\nError as follows.`)
console.error(e) console.error(e)
} }
} }
@ -58,6 +55,6 @@ async function handleMessage({ email, msg }: { email: Email, msg: FetchMessageOb
(async () => { (async () => {
const email = new Email() const email = new Email()
email.once('message', (msg: FetchMessageObject) => handleMessage({ email, msg })) email.on('message', (msg: FetchMessageObject) => handleMessage({ email, msg }))
await email.connect() await email.connect()
})() })()

View File

@ -11,6 +11,8 @@ import fs from 'node:fs'
if (!process.env.S3_BUCKET_NAME) throw new Error('S3_BUCKET_NAME was undefined in env'); if (!process.env.S3_BUCKET_NAME) throw new Error('S3_BUCKET_NAME was undefined in env');
if (!process.env.SCOUT_NITTER_URL) throw new Error('SCOUT_NITTER_URL was undefined in env'); if (!process.env.SCOUT_NITTER_URL) throw new Error('SCOUT_NITTER_URL was undefined in env');
if (!process.env.S3_BUCKET_KEY_ID) throw new Error('S3_BUCKET_KEY_ID was undefined in env');
if (!process.env.S3_BUCKET_APPLICATION_KEY) throw new Error('S3_BUCKET_APPLICATION_KEY was undefined in env');

View File

@ -4,7 +4,6 @@ import qs from 'qs'
import { subMinutes, addMinutes } from 'date-fns' import { subMinutes, addMinutes } from 'date-fns'
import { fpSlugify, download } from './utils.js' import { fpSlugify, download } from './utils.js'
import { getProminentColor } from './image.js' import { getProminentColor } from './image.js'
import { RateLimiter } from "limiter"
import { getImage } from './vtuber.js' import { getImage } from './vtuber.js'
import fansly from './fansly.js' import fansly from './fansly.js'
@ -36,7 +35,6 @@ if (!process.env.CDN_BUCKET_URL) throw new Error('CDN_BUCKET_URL is undefined in
*/ */
export async function createStreamInDb ({ source, platform, channel, date, url, userId }) { export async function createStreamInDb ({ source, platform, channel, date, url, userId }) {
// const limiter = new RateLimiter({ tokensPerInterval: 0.3, interval: "second" });
let vtuberId, streamId let vtuberId, streamId
console.log('>> # Step 1') console.log('>> # Step 1')
@ -117,7 +115,7 @@ export async function createStreamInDb ({ source, platform, channel, date, url,
const b2FileData = await s3.uploadFile(imageFile) const b2FileData = await s3.uploadFile(imageFile)
// get b2 cdn link to image // get b2 cdn link to image
const imageCdnLink = `https://${process.env.CDN_BUCKET_URL}/${b2FileData.Key}` const imageCdnLink = `${process.env.CDN_BUCKET_URL}/${b2FileData.Key}`
const createVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers`, { const createVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers`, {

View File

@ -6,12 +6,12 @@
import fetch from "node-fetch" import fetch from "node-fetch"
import { NotificationData, processEmail } from "./workflows.js" import { NotificationData, processEmail } from "./workflows.js"
import qs from 'qs' import qs from 'qs'
import { IVtuberResponse } from 'next' import { IPlatformNotificationResponse, IVtuberResponse, IStreamResponse } from 'next'
import { getImage } from '../vtuber.js' import { getImage } from '../vtuber.js'
import { fpSlugify, download } from '../utils.js' import { fpSlugify } from '../utils.js'
import fansly from '../fansly.js'
import { getProminentColor } from '../image.js' import { getProminentColor } from '../image.js'
import { uploadFile } from '../s3.js' import { uploadFile } from '../s3.js'
import { addMinutes, subMinutes } from 'date-fns'
export type ChargeResult = { export type ChargeResult = {
status: string; status: string;
@ -42,113 +42,286 @@ export async function upsertVtuber({ platform, userId, url, channel }: Notificat
// GET /api/:pluralApiId?filters[field][operator]=value // GET /api/:pluralApiId?filters[field][operator]=value
const findVtubersFilters = (() => { const findVtubersFilters = (() => {
if (platform === 'chaturbate') { if (platform === 'chaturbate') {
return { chaturbate: { $eq: url } } return { chaturbate: { $eq: url } }
} else if (platform === 'fansly') { } else if (platform === 'fansly') {
if (!userId) throw new Error('Fansly userId was undefined, but it is required.') if (!userId) throw new Error('Fansly userId was undefined, but it is required.')
return { fanslyId: { $eq: userId } } return { fanslyId: { $eq: userId } }
} }
})() })()
console.log('>>>>> the following is findVtubersFilters.') console.log('>>>>> the following is findVtubersFilters.')
console.log(findVtubersFilters) console.log(findVtubersFilters)
const findVtubersQueryString = qs.stringify({ const findVtubersQueryString = qs.stringify({
filters: findVtubersFilters filters: findVtubersFilters
}, { encode: false }) }, { encode: false })
console.log(`>>>>> platform=${platform}, url=${url}, userId=${userId}`) console.log(`>>>>> platform=${platform}, url=${url}, userId=${userId}`)
console.log('>> findVtuber') console.log('>> findVtuber')
const findVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers?${findVtubersQueryString}`, { const findVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers?${findVtubersQueryString}`, {
method: 'GET', method: 'GET',
headers: { headers: {
'content-type': 'application/json' 'content-type': 'application/json'
} }
}) })
const findVtuberJson = await findVtuberRes.json() as IVtuberResponse const findVtuberJson = await findVtuberRes.json() as IVtuberResponse
console.log('>> here is the vtuber json') console.log('>> here is the vtuber json')
console.log(findVtuberJson) console.log(findVtuberJson)
if (findVtuberJson?.data && findVtuberJson.data.length > 0) { if (findVtuberJson?.data && findVtuberJson.data.length > 0) {
console.log('>>a vtuber was FOUND') console.log('>> a vtuber was FOUND')
if (findVtuberJson.data.length > 1) throw new Error('There was more than one vtuber match. There must only be one.') if (findVtuberJson.data.length > 1) throw new Error('There was more than one vtuber match. There must only be one.')
vtuberId = findVtuberJson.data[0].id vtuberId = findVtuberJson.data[0].id
console.log('here is the findVtuberJson (as follows)') console.log('here is the findVtuberJson (as follows)')
console.log(findVtuberJson) console.log(findVtuberJson)
console.log(`the matching vtuber has ID=${vtuberId} (${findVtuberJson.data[0].attributes.displayName})`) console.log(`the matching vtuber has ID=${vtuberId} (${findVtuberJson.data[0].attributes.displayName})`)
} }
if (!vtuberId) { if (!vtuberId) {
console.log('>> vtuberId was not found so we create') console.log('>> vtuberId was not found so we create')
/** /**
* We are creating a vtuber record. * We are creating a vtuber record.
* We need a few things. * We need a few things.
* * image URL * * image URL
* * themeColor * * themeColor
* *
* To get an image, we have to do a few things. * To get an image, we have to do a few things.
* * [x] download image from platform * * [x] download image from platform
* * [x] get themeColor from image * * [x] get themeColor from image
* * [x] upload image to b2 * * [x] upload image to b2
* * [x] get B2 cdn link to image * * [x] get B2 cdn link to image
* *
* To get themeColor, we need the image locally where we can then run * To get themeColor, we need the image locally where we can then run
*/ */
// download image from platform // download image from platform
// vtuber.getImage expects a vtuber object, which we don't have yet, so we create a dummy one // vtuber.getImage expects a vtuber object, which we don't have yet, so we create a dummy one
const dummyVtuber = { const dummyVtuber = {
attributes: { attributes: {
slug: fpSlugify(channel), slug: fpSlugify(channel),
fansly: fansly.url.fromUsername(channel) fanslyId: (platform === 'fansly') ? userId : null
}
} }
const platformImageUrl = await getImage(dummyVtuber) }
const imageFile = await download({ url: platformImageUrl }) const imageFile = await getImage(dummyVtuber)
// get themeColor from image // get themeColor from image
const themeColor = await getProminentColor(imageFile) const themeColor = await getProminentColor(imageFile)
// upload image to b2 // upload image to b2
const b2FileData = await uploadFile(imageFile) const b2FileData = await uploadFile(imageFile)
// get b2 cdn link to image // get b2 cdn link to image
const imageCdnLink = `https://${process.env.CDN_BUCKET_URL}/${b2FileData.Key}` const imageCdnLink = `${process.env.CDN_BUCKET_URL}/${b2FileData.Key}`
const createVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers`, { const createVtuberRes = await fetch(`${process.env.STRAPI_URL}/api/vtubers`, {
method: 'POST', method: 'POST',
headers: { headers: {
'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`, 'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`,
'content-type': 'application/json' 'content-type': 'application/json'
}, },
body: JSON.stringify({ body: JSON.stringify({
data: { data: {
displayName: channel, displayName: channel,
fansly: (platform === 'fansly') ? url : null, fansly: (platform === 'fansly') ? url : null,
fanslyId: (platform === 'fansly') ? userId : null, fanslyId: (platform === 'fansly') ? userId : null,
chaturbate: (platform === 'chaturbate') ? url : null, chaturbate: (platform === 'chaturbate') ? url : null,
slug: fpSlugify(channel), slug: fpSlugify(channel),
description1: ' ', description1: ' ',
image: imageCdnLink, image: imageCdnLink,
themeColor: themeColor || '#dde1ec' themeColor: themeColor || '#dde1ec'
} }
})
}) })
const createVtuberJson = await createVtuberRes.json() as IVtuberResponse })
console.log('>> createVtuberJson as follows') const createVtuberJson = await createVtuberRes.json() as IVtuberResponse
console.log(JSON.stringify(createVtuberJson, null, 2)) console.log('>> createVtuberJson as follows')
if (createVtuberJson.data) { console.log(JSON.stringify(createVtuberJson, null, 2))
vtuberId = createVtuberJson.data.id if (createVtuberJson.data) {
console.log(`>>> vtuber created with id=${vtuberId}`) vtuberId = createVtuberJson.data.id
} console.log(`>>> vtuber created with id=${vtuberId}`)
}
} }
return 777 return vtuberId
} }
export async function upsertPlatformNotification(): Promise<number> { export async function upsertPlatformNotification({ source, date, platform, vtuberId }: { source: string, date: string, platform: string, vtuberId: number }): Promise<number> {
return 777 if (!source) throw new Error(`upsertPlatformNotification requires source arg, but it was undefined`);
if (!date) throw new Error(`upsertPlatformNotification requires date arg, but it was undefined`);
if (!platform) throw new Error(`upsertPlatformNotification requires platform arg, but it was undefined`);
if (!vtuberId) throw new Error(`upsertPlatformNotification requires vtuberId arg, but it was undefined`);
let pNotifId
// # Step 2.
// Next we create the platform-notification record.
// This probably doesn't already exist, so we don't check for a pre-existing platform-notification.
const pNotifPayload = {
data: {
source: source,
date: date,
date2: date,
platform: platform,
vtuber: vtuberId,
}
}
console.log('pNotifPayload as follows')
console.log(pNotifPayload)
const pNotifCreateRes = await fetch(`${process.env.STRAPI_URL}/api/platform-notifications`, {
method: 'POST',
headers: {
'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(pNotifPayload)
})
const pNotifData = await pNotifCreateRes.json() as IPlatformNotificationResponse
if (pNotifData.error) {
console.error('>> we failed to create platform-notification, there was an error in the response')
console.error(JSON.stringify(pNotifData.error, null, 2))
throw new Error(pNotifData.error)
}
console.log(`>> pNotifData (json response) is as follows`)
console.log(pNotifData)
if (!pNotifData.data?.id) throw new Error('failed to created pNotifData! The response was missing an id');
pNotifId = pNotifData.data.id
if (!pNotifId) throw new Error('failed to get Platform Notification ID');
return pNotifId
} }
export async function upsertStream(): Promise<number> { export async function upsertStream({
return 777 date,
vtuberId,
platform,
pNotifId
}: {
date: string,
vtuberId: number,
platform: string,
pNotifId: number
}): Promise<number> {
if (!date) throw new Error(`upsertStream requires date in the arg object, but it was undefined`);
if (!vtuberId) throw new Error(`upsertStream requires vtuberId in the arg object, but it was undefined`);
if (!platform) throw new Error(`upsertStream requires platform in the arg object, but it was undefined`);
if (!pNotifId) throw new Error(`upsertStream requires pNotifId in the arg object, but it was undefined`);
let streamId
// # Step 3.
// Finally we find or create the stream record
// The stream may already be in the db (the streamer is multi-platform streaming), so we look for that record.
// This gets a bit tricky. How do we determine one stream from another?
// For now, the rule is 30 minutes of separation.
// Anything <=30m is interpreted as the same stream. Anything >30m is interpreted as a different stream.
// If the stream is not in the db, we create the stream record
const dateSinceRange = subMinutes(new Date(date), 30)
const dateUntilRange = addMinutes(new Date(date), 30)
console.log(`Find a stream within + or - 30 mins of the notif date=${new Date(date).toISOString()}. dateSinceRange=${dateSinceRange.toISOString()}, dateUntilRange=${dateUntilRange.toISOString()}`)
const findStreamQueryString = qs.stringify({
populate: 'platform-notifications',
filters: {
date: {
$gte: dateSinceRange,
$lte: dateUntilRange
},
vtuber: {
id: {
'$eq': vtuberId
}
}
}
}, { encode: false })
console.log('>> findStream')
const findStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams?${findStreamQueryString}`, {
method: 'GET',
headers: {
'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`,
'Content-Type': 'application/json'
}
})
const findStreamData = await findStreamRes.json() as IStreamResponse
if (findStreamData?.data && findStreamData.data.length > 0) {
console.log('>> we found a findStreamData json. (there is an existing stream for this e-mail/notification)')
console.log(JSON.stringify(findStreamData, null, 2))
streamId = findStreamData.data[0].id
// Before we're done here, we need to do something extra. We need to populate isChaturbateStream and/or isFanslyStream.
// We know which of these booleans to set based on the stream's related platformNotifications
// We go through each pNotif and look at it's platform
let isFanslyStream = false
let isChaturbateStream = false
if (findStreamData.data[0].attributes.platformNotifications) {
for (const pn of findStreamData.data[0].attributes.platformNotifications) {
if (pn.platform === 'fansly') {
isFanslyStream = true
} else if (pn.platform === 'chaturbate') {
isChaturbateStream = true
}
}
}
console.log(`>>> updating stream ${streamId}. isFanslyStream=${isFanslyStream}, isChaturbateStream=${isChaturbateStream}`)
const updateStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams/${streamId}`, {
method: 'PUT',
headers: {
'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`,
'content-type': 'application/json'
},
body: JSON.stringify({
data: {
isFanslyStream: isFanslyStream,
isChaturbateStream: isChaturbateStream,
platformNotifications: [
pNotifId
]
}
})
})
const updateStreamJson = await updateStreamRes.json() as IStreamResponse
if (updateStreamJson?.error) throw new Error(JSON.stringify(updateStreamJson, null, 2));
console.log(`>> assuming a successful update to the stream record. response as follows.`)
console.log(JSON.stringify(updateStreamJson, null, 2))
}
if (!streamId) {
console.log('>> did not find a streamId, so we go ahead and create a stream record in the db.')
const createStreamPayload = {
data: {
isFanslyStream: (platform === 'fansly') ? true : false,
isChaturbateStream: (platform === 'chaturbate') ? true : false,
archiveStatus: 'missing',
date: date,
date2: date,
date_str: date,
vtuber: vtuberId,
platformNotifications: [
pNotifId
]
}
}
console.log('>> createStreamPayload as follows')
console.log(createStreamPayload)
const createStreamRes = await fetch(`${process.env.STRAPI_URL}/api/streams`, {
method: 'POST',
headers: {
'authorization': `Bearer ${process.env.SCOUT_STRAPI_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify(createStreamPayload)
})
const createStreamJson = await createStreamRes.json() as IStreamResponse
console.log('>> we got the createStreamJson')
console.log(createStreamJson)
if (createStreamJson.error) {
console.error(JSON.stringify(createStreamJson.error, null, 2))
throw new Error('Failed to create stream in DB due to an error. (see above)')
}
streamId = createStreamJson.id
}
if (!streamId) throw new Error('failed to get streamId')
return streamId
} }

View File

@ -1,6 +1,7 @@
import path from "path" import path from "path"
import { NativeConnection, Worker } from "@temporalio/worker" import { NativeConnection, Worker } from "@temporalio/worker"
import * as activities from "./activities.js" import * as activities from "./activities.js"
import pRetry from 'p-retry'
if (!process.env.TEMPORAL_SERVICE_ADDRESS) throw new Error(`TEMPORAL_SERVICE_ADDRESS is missing in env`); if (!process.env.TEMPORAL_SERVICE_ADDRESS) throw new Error(`TEMPORAL_SERVICE_ADDRESS is missing in env`);
@ -49,7 +50,11 @@ async function run() {
await worker.run(); await worker.run();
} }
run().catch((err) => { await pRetry(run, {
console.error(err); forever: true,
process.exit(1); onFailedAttempt: (e) => {
console.error(e);
console.error(`there was an error during scout-worker run(). run() will now restart.`)
console.log(`P.S., check out these booba --> (.)(.)`)
}
}) })

View File

@ -15,9 +15,6 @@ export type NotificationData = {
}; };
const { const {
chargeUser,
checkAndDecrementInventory,
incrementInventory,
upsertPlatformNotification, upsertPlatformNotification,
upsertStream, upsertStream,
upsertVtuber, upsertVtuber,
@ -39,10 +36,9 @@ export async function processEmail({
// Step 1 // Step 1
const vtuberId = await upsertVtuber({ url, platform, channel, displayName, date, userId, avatar }) const vtuberId = await upsertVtuber({ url, platform, channel, displayName, date, userId, avatar })
console.log('we have finished upsertVtuber and the vtuberId is '+vtuberId) const pNotifId = await upsertPlatformNotification({ vtuberId, source: 'email', date, platform })
throw new Error('Error: Error: error: erorreorororr; @todo'); const streamId = await upsertStream({ date, vtuberId, platform, pNotifId })
const pNotifId = await upsertPlatformNotification()
const streamId = await upsertStream()
return { vtuberId, pNotifId, streamId } return { vtuberId, pNotifId, streamId }
} }

View File

@ -19,9 +19,8 @@ const normalize = (url) => {
} }
const image = async function image (limiter, twitterUsername) { const image = async function image (twitterUsername) {
if (!limiter) throw new Error('first arg to twitter.data.image must be an instance of node-rate-limiter'); if (!twitterUsername) throw new Error('first arg to twitter.data.image must be a twitterUsername. It was undefined.');
if (!twitterUsername) throw new Error('second arg to twitter.data.image must be a twitterUsername. It was undefined.');
const requestDataFromNitter = async () => { const requestDataFromNitter = async () => {
const url = `${process.env.SCOUT_NITTER_URL}/${twitterUsername}/rss?key=${process.env.SCOUT_NITTER_ACCESS_KEY}` const url = `${process.env.SCOUT_NITTER_URL}/${twitterUsername}/rss?key=${process.env.SCOUT_NITTER_ACCESS_KEY}`
// console.log(`fetching from url=${url}`) // console.log(`fetching from url=${url}`)
@ -37,7 +36,7 @@ const image = async function image (limiter, twitterUsername) {
const dom = htmlparser2.parseDocument(body); const dom = htmlparser2.parseDocument(body);
const $ = load(dom, { _useHtmlParser2: true }) const $ = load(dom, { _useHtmlParser2: true })
const urls = $('url:contains("profile_images")').first() const urls = $('url:contains("profile_images")').first()
const downloadedImageFile = await download({ limiter, url: urls.text() }) const downloadedImageFile = await download({ url: urls.text() })
return downloadedImageFile return downloadedImageFile
} catch (e) { } catch (e) {
console.error(`while fetching rss from nitter, the following error was encountered.`) console.error(`while fetching rss from nitter, the following error was encountered.`)

View File

@ -2,7 +2,6 @@ import { expect } from 'chai'
import twitter from './twitter.js' import twitter from './twitter.js'
import { describe } from 'mocha' import { describe } from 'mocha'
import { tmpFileRegex } from './utils.js' import { tmpFileRegex } from './utils.js'
import { RateLimiter } from 'limiter'
describe('twitter', function () { describe('twitter', function () {
describe('regex', function () { describe('regex', function () {
@ -19,10 +18,9 @@ describe('twitter', function () {
}) })
describe('data', function () { describe('data', function () {
this.timeout(1000*30) this.timeout(1000*30)
const limiter = new RateLimiter({ tokensPerInterval: 10, interval: "second" })
describe('image', function () { describe('image', function () {
it("should download the twitter users's avatar and save it to disk", async function () { it("should download the twitter users's avatar and save it to disk", async function () {
const imgFile = await twitter.data.image(limiter, 'projektmelody') const imgFile = await twitter.data.image('projektmelody')
expect(imgFile).to.match(tmpFileRegex) expect(imgFile).to.match(tmpFileRegex)
}) })
}) })

View File

@ -24,19 +24,16 @@ export function getTmpFile(str) {
/** /**
* *
* @param {Object} limiter [https://github.com/jhurliman/node-rate-limiter](node-rate-limiter) instance
* @param {String} url * @param {String} url
* @returns {String} filePath * @returns {String} filePath
* *
* greetz https://stackoverflow.com/a/74722818/1004931 * greetz https://stackoverflow.com/a/74722818/1004931
*/ */
export async function download({ limiter, url, filePath }) { export async function download({ url, filePath }) {
if (!limiter) throw new Error(`first arg passed to download() must be a node-rate-limiter instance.`);
if (!url) throw new Error(`second arg passed to download() must be a {string} url`); if (!url) throw new Error(`second arg passed to download() must be a {string} url`);
const fileBaseName = basename(url) const fileBaseName = basename(url)
filePath = filePath || path.join(os.tmpdir(), `${createId()}_${fileBaseName}`) filePath = filePath || path.join(os.tmpdir(), `${createId()}_${fileBaseName}`)
const stream = fs.createWriteStream(filePath) const stream = fs.createWriteStream(filePath)
await limiter.removeTokens(1);
const requestData = async () => { const requestData = async () => {
const response = await fetch(url, { const response = await fetch(url, {

View File

@ -1,7 +1,6 @@
import { fpSlugify, getTmpFile, download } from './utils.js' import { fpSlugify, getTmpFile, download } from './utils.js'
import { expect } from 'chai' import { expect } from 'chai'
import { describe } from 'mocha' import { describe } from 'mocha'
import { RateLimiter } from "limiter"
describe('utils', function () { describe('utils', function () {
@ -18,9 +17,8 @@ describe('utils', function () {
}) })
}), }),
describe('download', function () { describe('download', function () {
const limiter = new RateLimiter({ tokensPerInterval: 100, interval: "second" })
it('should get the file', async function () { it('should get the file', async function () {
const file = await download({ limiter, url: 'https://futureporn-b2.b-cdn.net/sample.webp' }) const file = await download({ url: 'https://futureporn-b2.b-cdn.net/sample.webp' })
expect(file).to.match(/\/tmp\/.*sample\.webp$/) expect(file).to.match(/\/tmp\/.*sample\.webp$/)
}) })
}) })

View File

@ -20,25 +20,22 @@ import fansly from './fansly.js'
* *
* We depend on one of these social media URLs. If there is neither Twitter or fansly listed, we throw an error. * We depend on one of these social media URLs. If there is neither Twitter or fansly listed, we throw an error.
* *
* @param {Object} limiter -- instance of node-rate-limiter
* @param {Object} vtuber -- vtuber instance from strapi * @param {Object} vtuber -- vtuber instance from strapi
* @returns {String} filePath -- path on disk where the image was saved * @returns {String} filePath -- path on disk where the image was saved
*/ */
export async function getImage(limiter, vtuber) { export async function getImage(vtuber) {
if (!limiter) throw new Error('first arg must be node-rate-limiter instace'); if (!vtuber) throw new Error('first arg must be vtuber instance');
if (!vtuber) throw new Error('second arg must be vtuber instance');
await limiter.removeTokens(1);
const { twitter: twitterUrl, fanslyId: fanslyId } = vtuber.attributes const { twitter: twitterUrl, fanslyId: fanslyId } = vtuber.attributes
const twitterUsername = twitterUrl && twitter.regex.username.exec(twitterUrl).at(1) const twitterUsername = twitterUrl && twitter.regex.username.exec(twitterUrl).at(1)
let img; let img;
if (twitterUrl) { if (twitterUrl) {
img = await twitter.data.image(limiter, twitterUsername) img = await twitter.data.image(twitterUsername)
} else if (fanslyId) { } else if (fanslyId) {
img = await fansly.data.image(limiter, fanslyId) img = await fansly.data.image(fanslyId)
} else { } else {
const msg = 'while attempting to get vtuber image, there was neither twitter nor fansly listed. One of these must exist for us to download an image.' const msg = ` while attempting to get vtuber image, there was neither twitterUrl nor fanslyId listed. One of these must exist for us to download an image. \nvtuber=${JSON.stringify(vtuber, null, 2)}`
console.error(msg) console.error(msg)
throw new Error(msg) throw new Error(msg)
} }

View File

@ -1,7 +1,6 @@
import { expect } from 'chai' import { expect } from 'chai'
import { describe } from 'mocha' import { describe } from 'mocha'
import { RateLimiter } from 'limiter'
import { getImage } from './vtuber.js' import { getImage } from './vtuber.js'
import { tmpFileRegex } from './utils.js' import { tmpFileRegex } from './utils.js'
@ -25,13 +24,12 @@ const vtuberFixture1 = {
describe('vtuber', function () { describe('vtuber', function () {
this.timeout(1000*60) this.timeout(1000*60)
describe('getImage', function () { describe('getImage', function () {
const limiter = new RateLimiter({ tokensPerInterval: 1, interval: "second" })
it('should download an avatar image from twitter', async function () { it('should download an avatar image from twitter', async function () {
const file = await getImage(limiter, vtuberFixture0) const file = await getImage(vtuberFixture0)
expect(file).to.match(tmpFileRegex) expect(file).to.match(tmpFileRegex)
}) })
it('should download an avatar image from fansly', async function () { it('should download an avatar image from fansly', async function () {
const file = await getImage(limiter, vtuberFixture1) const file = await getImage(vtuberFixture1)
expect(file).to.match(tmpFileRegex) expect(file).to.match(tmpFileRegex)
}) })
}) })

View File

@ -5,3 +5,9 @@
* ironmouse "Thank you" (for testing): 4760169 * ironmouse "Thank you" (for testing): 4760169
* cj_clippy "Full library access" (for production): 9380584 * cj_clippy "Full library access" (for production): 9380584
* cj_clippy "Your URL displayed on Futureporn.net": 10663202 * cj_clippy "Your URL displayed on Futureporn.net": 10663202
### Content-Type Builder (Docker caveat)
Don't use the web UI to create or update Content-Types! The changes will be lost. This is a side-effect of our hacked together solution for Strapi with pnpm in docker.
Instead, content-type schemas must be hand-edited in ./src/api/(...). For the changes to take effect, trigger a strapi resource update in Tilt.

View File

@ -45,6 +45,12 @@
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "oneToOne",
"target": "api::vtuber.vtuber" "target": "api::vtuber.vtuber"
},
"stream": {
"type": "relation",
"relation": "manyToOne",
"target": "api::stream.stream",
"inversedBy": "platformNotifications"
} }
} }
} }

View File

@ -22,11 +22,6 @@ module.exports = {
async afterUpdate(event) { async afterUpdate(event) {
/**
* NOTE
*
* These hooks do not fire in response to API calls. They only fire in response to UI saves.
*/
console.log(`>>>>>>>>>>>>>> STREAM is afterUpdate !!!!!!!!!!!!`); console.log(`>>>>>>>>>>>>>> STREAM is afterUpdate !!!!!!!!!!!!`);
const { data, where, select, populate } = event.params; const { data, where, select, populate } = event.params;
@ -93,12 +88,14 @@ module.exports = {
console.log(`lets find the platformNotifications`) console.log(`lets find the platformNotifications`)
console.log(JSON.stringify(existingData2, null, 2)) console.log(JSON.stringify(existingData2, null, 2))
// Iterate through all vods to determine archiveStatus // Iterate through all platformNotifications to determine platform
for (const pn of existingData2.platform_notifications) { if (existingData2.platformNotifications) {
if (pn.platform === 'fansly') { for (const pn of existingData2.platformNotifications) {
isFanslyStream = true if (pn.platform === 'fansly') {
} else if (pn.platform === 'chaturbate') { isFanslyStream = true
isChaturbateStream = true } else if (pn.platform === 'chaturbate') {
isChaturbateStream = true
}
} }
} }

View File

@ -12,6 +12,12 @@
}, },
"pluginOptions": {}, "pluginOptions": {},
"attributes": { "attributes": {
"platformNotifications": {
"type": "relation",
"relation": "oneToMany",
"target": "api::platform-notification.platform-notification",
"mappedBy": "stream"
},
"date_str": { "date_str": {
"type": "string", "type": "string",
"required": true, "required": true,

View File

@ -79,7 +79,7 @@
}, },
"description1": { "description1": {
"type": "text", "type": "text",
"required": true "required": false
}, },
"description2": { "description2": {
"type": "text" "type": "text"
@ -113,6 +113,15 @@
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::stream.stream", "target": "api::stream.stream",
"mappedBy": "vtuber" "mappedBy": "vtuber"
},
"fanslyId": {
"type": "string"
},
"chaturbateId": {
"type": "string"
},
"twitterId": {
"type": "string"
} }
} }
} }

View File

@ -39,7 +39,9 @@ kubectl --namespace futureporn create secret generic scout \
--from-literal=imapUsername=${SCOUT_IMAP_USERNAME} \ --from-literal=imapUsername=${SCOUT_IMAP_USERNAME} \
--from-literal=imapPassword=${SCOUT_IMAP_PASSWORD} \ --from-literal=imapPassword=${SCOUT_IMAP_PASSWORD} \
--from-literal=imapAccessToken=${SCOUT_IMAP_ACCESS_TOKEN} \ --from-literal=imapAccessToken=${SCOUT_IMAP_ACCESS_TOKEN} \
--from-literal=nitterAccessKey=${SCOUT_NITTER_ACCESS_KEY} --from-literal=nitterAccessKey=${SCOUT_NITTER_ACCESS_KEY} \
--from-literal=s3BucketKeyId=${S3_BUCKET_KEY_ID} \
--from-literal=s3BucketApplicationKey=${S3_BUCKET_APPLICATION_KEY}
kubectl --namespace futureporn delete secret link2cid --ignore-not-found kubectl --namespace futureporn delete secret link2cid --ignore-not-found
kubectl --namespace futureporn create secret generic link2cid \ kubectl --namespace futureporn create secret generic link2cid \