mirror of
https://github.com/omnivore-app/omnivore.git
synced 2026-03-11 08:54:26 +00:00
Merge pull request #3661 from omnivore-app/feat/job-youtube-transcript-generator
Pull metadata from YouTube for library items
This commit is contained in:
commit
4e50c51598
12 changed files with 833 additions and 5 deletions
|
|
@ -46,6 +46,7 @@
|
|||
"@sentry/integrations": "^7.10.0",
|
||||
"@sentry/node": "^5.26.0",
|
||||
"@sentry/tracing": "^7.9.0",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"addressparser": "^1.0.1",
|
||||
"apollo-datasource": "^3.3.1",
|
||||
"apollo-server-express": "^3.6.3",
|
||||
|
|
@ -97,6 +98,7 @@
|
|||
"sanitize-html": "^2.3.2",
|
||||
"sax": "^1.3.0",
|
||||
"search-query-parser": "^1.6.0",
|
||||
"showdown": "^2.1.0",
|
||||
"snake-case": "^3.0.3",
|
||||
"supertest": "^6.2.2",
|
||||
"ts-loader": "^9.3.0",
|
||||
|
|
@ -107,7 +109,9 @@
|
|||
"uuid": "^8.3.1",
|
||||
"voca": "^1.4.0",
|
||||
"winston": "^3.3.3",
|
||||
"word-counting": "^1.1.4"
|
||||
"word-counting": "^1.1.4",
|
||||
"youtubei": "^1.3.4",
|
||||
"youtubei.js": "^9.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/register": "^7.14.5",
|
||||
|
|
@ -136,6 +140,7 @@
|
|||
"@types/private-ip": "^1.0.0",
|
||||
"@types/sanitize-html": "^1.27.1",
|
||||
"@types/sax": "^1.2.7",
|
||||
"@types/showdown": "^2.0.6",
|
||||
"@types/sinon": "^10.0.13",
|
||||
"@types/sinon-chai": "^3.2.8",
|
||||
"@types/supertest": "^2.0.11",
|
||||
|
|
|
|||
521
packages/api/src/jobs/process-youtube-video.ts
Normal file
521
packages/api/src/jobs/process-youtube-video.ts
Normal file
|
|
@ -0,0 +1,521 @@
|
|||
import { logger } from '../utils/logger'
|
||||
import { authTrx } from '../repository'
|
||||
import { libraryItemRepository } from '../repository/library_item'
|
||||
import { LibraryItem, LibraryItemState } from '../entity/library_item'
|
||||
|
||||
import { Chapter, Client as YouTubeClient } from 'youtubei'
|
||||
import showdown from 'showdown'
|
||||
import { parseHTML } from 'linkedom'
|
||||
import { parsePreparedContent } from '../utils/parser'
|
||||
import { OpenAI } from '@langchain/openai'
|
||||
import { PromptTemplate } from '@langchain/core/prompts'
|
||||
import { enqueueProcessYouTubeTranscript } from '../utils/createTask'
|
||||
import { env } from '../env'
|
||||
import * as stream from 'stream'
|
||||
|
||||
import { Storage } from '@google-cloud/storage'
|
||||
import { stringToHash } from '../utils/helpers'
|
||||
import { FeatureName, findFeatureByName } from '../services/features'
|
||||
|
||||
export interface ProcessYouTubeVideoJobData {
|
||||
userId: string
|
||||
libraryItemId: string
|
||||
}
|
||||
|
||||
export const PROCESS_YOUTUBE_VIDEO_JOB_NAME = 'process-youtube-video'
|
||||
export const PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME = 'process-youtube-transcript'
|
||||
|
||||
const TRANSCRIPT_PLACEHOLDER_TEXT =
|
||||
'* Omnivore is preparing a transcript for this video'
|
||||
|
||||
const calculateWordCount = (durationInSeconds: number): number => {
|
||||
// Calculate word count using the formula: word count = read time (in seconds) * words per second
|
||||
// Assuming average reading speed is 235 words per minute (or about 3.92 words per second)
|
||||
const wordsPerSecond = 3.92
|
||||
const wordCount = Math.round(durationInSeconds * wordsPerSecond)
|
||||
return wordCount
|
||||
}
|
||||
|
||||
interface ChapterProperties {
|
||||
title: string
|
||||
start: number
|
||||
}
|
||||
|
||||
interface TranscriptProperties {
|
||||
text: string
|
||||
start: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
export const addTranscriptChapters = (
|
||||
chapters: ChapterProperties[],
|
||||
transcript: TranscriptProperties[]
|
||||
): TranscriptProperties[] => {
|
||||
chapters.sort((a, b) => a.start - b.start)
|
||||
|
||||
for (const chapter of chapters) {
|
||||
const startOffset = chapter.start
|
||||
const title = '\n\n## ' + chapter.title + '\n\n'
|
||||
|
||||
const index = transcript.findIndex(
|
||||
(textItem) => textItem.start > startOffset
|
||||
)
|
||||
|
||||
if (index !== -1) {
|
||||
transcript.splice(index, 0, {
|
||||
text: title,
|
||||
duration: 1,
|
||||
start: startOffset,
|
||||
})
|
||||
} else {
|
||||
transcript.push({ text: title, duration: 0, start: startOffset })
|
||||
}
|
||||
}
|
||||
return transcript
|
||||
}
|
||||
|
||||
const createTranscriptHash = (transcript: TranscriptProperties[]): string => {
|
||||
const rawTranscript = transcript.map((item) => item.text).join(' ')
|
||||
return stringToHash(rawTranscript)
|
||||
}
|
||||
|
||||
export const createTranscriptHTML = async (
|
||||
videoId: string,
|
||||
transcript: TranscriptProperties[]
|
||||
): Promise<string> => {
|
||||
let transcriptMarkdown = ''
|
||||
const transcriptHash = createTranscriptHash(transcript)
|
||||
const promptHash = stringToHash(process.env.YOUTUBE_TRANSCRIPT_PROMPT ?? '')
|
||||
|
||||
if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) {
|
||||
const cachedTranscriptHTML = await fetchCachedYouTubeTranscript(
|
||||
videoId,
|
||||
transcriptHash,
|
||||
promptHash
|
||||
)
|
||||
if (cachedTranscriptHTML) {
|
||||
return cachedTranscriptHTML
|
||||
}
|
||||
|
||||
const llm = new OpenAI({
|
||||
modelName: 'gpt-4',
|
||||
configuration: {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
},
|
||||
})
|
||||
const promptTemplate = PromptTemplate.fromTemplate(
|
||||
`${process.env.YOUTUBE_TRANSCRIPT_PROMPT}
|
||||
|
||||
{transcriptData}`
|
||||
)
|
||||
const chain = promptTemplate.pipe(llm)
|
||||
|
||||
let transcriptChunkLength = 0
|
||||
let transcriptChunk: TranscriptProperties[] = []
|
||||
for (const item of transcript) {
|
||||
if (transcriptChunkLength + item.text.length > 8000) {
|
||||
const result = await chain.invoke({
|
||||
transcriptData: transcriptChunk.map((item) => item.text).join(' '),
|
||||
})
|
||||
|
||||
transcriptMarkdown += result
|
||||
|
||||
transcriptChunk = []
|
||||
transcriptChunkLength = 0
|
||||
}
|
||||
|
||||
transcriptChunk.push(item)
|
||||
transcriptChunkLength += item.text.length
|
||||
}
|
||||
|
||||
if (transcriptChunk.length > 0) {
|
||||
const result = await chain.invoke({
|
||||
transcriptData: transcriptChunk.map((item) => item.text).join(' '),
|
||||
})
|
||||
|
||||
transcriptMarkdown += result
|
||||
}
|
||||
}
|
||||
|
||||
// If the LLM didn't give us enough data fallback to the raw template
|
||||
if (transcriptMarkdown.length < 1) {
|
||||
transcriptMarkdown = transcript.map((item) => item.text).join(' ')
|
||||
}
|
||||
|
||||
const converter = new showdown.Converter({
|
||||
backslashEscapesHTMLTags: true,
|
||||
})
|
||||
const transcriptHTML = converter.makeHtml(transcriptMarkdown)
|
||||
|
||||
if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) {
|
||||
await cacheYouTubeTranscript(
|
||||
videoId,
|
||||
transcriptHash,
|
||||
promptHash,
|
||||
transcriptHTML
|
||||
)
|
||||
}
|
||||
|
||||
return transcriptHTML
|
||||
}
|
||||
|
||||
export const addTranscriptToReadableContent = async (
|
||||
originalUrl: string,
|
||||
originalHTML: string,
|
||||
transcriptHTML: string
|
||||
): Promise<string | undefined> => {
|
||||
const html = parseHTML(originalHTML)
|
||||
|
||||
const transcriptNode = html.document.querySelector(
|
||||
'#_omnivore_youtube_transcript'
|
||||
)
|
||||
|
||||
if (transcriptNode) {
|
||||
transcriptNode.innerHTML = transcriptHTML
|
||||
} else {
|
||||
const div = html.document.createElement('div')
|
||||
div.innerHTML = transcriptHTML
|
||||
html.document.body.appendChild(div)
|
||||
}
|
||||
|
||||
const preparedDocument = {
|
||||
document: html.document.toString(),
|
||||
pageInfo: {},
|
||||
}
|
||||
const updatedContent = await parsePreparedContent(
|
||||
originalUrl,
|
||||
preparedDocument,
|
||||
true
|
||||
)
|
||||
return updatedContent.parsedContent?.content
|
||||
}
|
||||
|
||||
export const addTranscriptPlaceholdReadableContent = async (
|
||||
originalUrl: string,
|
||||
originalHTML: string
|
||||
): Promise<string | undefined> => {
|
||||
const html = parseHTML(originalHTML)
|
||||
|
||||
const transcriptNode = html.document.querySelector(
|
||||
'#_omnivore_youtube_transcript'
|
||||
)
|
||||
|
||||
if (transcriptNode) {
|
||||
transcriptNode.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT
|
||||
} else {
|
||||
const div = html.document.createElement('div')
|
||||
div.innerHTML = TRANSCRIPT_PLACEHOLDER_TEXT
|
||||
html.document.body.appendChild(div)
|
||||
}
|
||||
|
||||
const preparedDocument = {
|
||||
document: html.document.toString(),
|
||||
pageInfo: {},
|
||||
}
|
||||
const updatedContent = await parsePreparedContent(
|
||||
originalUrl,
|
||||
preparedDocument,
|
||||
true
|
||||
)
|
||||
return updatedContent.parsedContent?.content
|
||||
}
|
||||
|
||||
async function readStringFromStorage(
|
||||
bucketName: string,
|
||||
fileName: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const storage = env.fileUpload?.gcsUploadSAKeyFilePath
|
||||
? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath })
|
||||
: new Storage()
|
||||
|
||||
const existsResponse = await storage
|
||||
.bucket(bucketName)
|
||||
.file(fileName)
|
||||
.exists()
|
||||
const exists = existsResponse[0]
|
||||
|
||||
if (!exists) {
|
||||
throw new Error(
|
||||
`File '${fileName}' does not exist in bucket '${bucketName}'.`
|
||||
)
|
||||
}
|
||||
|
||||
// Download the file contents as a string
|
||||
const fileContentResponse = await storage
|
||||
.bucket(bucketName)
|
||||
.file(fileName)
|
||||
.download()
|
||||
const fileContent = fileContentResponse[0].toString()
|
||||
|
||||
console.log(`File '${fileName}' downloaded successfully as string.`)
|
||||
return fileContent
|
||||
} catch (error) {
|
||||
console.error('Error downloading file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const writeStringToStorage = async (
|
||||
bucketName: string,
|
||||
fileName: string,
|
||||
content: string
|
||||
): Promise<void> => {
|
||||
try {
|
||||
const storage = env.fileUpload?.gcsUploadSAKeyFilePath
|
||||
? new Storage({ keyFilename: env.fileUpload.gcsUploadSAKeyFilePath })
|
||||
: new Storage()
|
||||
|
||||
const writableStream = storage
|
||||
.bucket(bucketName)
|
||||
.file(fileName)
|
||||
.createWriteStream()
|
||||
|
||||
// Convert the string content to a readable stream
|
||||
const readableStream = new stream.Readable()
|
||||
readableStream.push(content)
|
||||
readableStream.push(null) // Signal the end of the stream
|
||||
|
||||
// Pipe the readable stream to the writable stream to upload the file content
|
||||
await new Promise((resolve, reject) => {
|
||||
readableStream
|
||||
.pipe(writableStream)
|
||||
.on('finish', resolve)
|
||||
.on('error', reject)
|
||||
})
|
||||
|
||||
console.log(
|
||||
`File '${fileName}' uploaded successfully to bucket '${bucketName}'.`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error uploading file:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const fetchCachedYouTubeTranscript = async (
|
||||
videoId: string,
|
||||
transcriptHash: string,
|
||||
promptHash: string
|
||||
): Promise<string | undefined> => {
|
||||
const bucketName = env.fileUpload.gcsUploadBucket
|
||||
|
||||
try {
|
||||
return await readStringFromStorage(
|
||||
bucketName,
|
||||
`youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html`
|
||||
)
|
||||
} catch (err) {
|
||||
logger.info(`unable to fetch cached transcript`, { error: err })
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
const cacheYouTubeTranscript = async (
|
||||
videoId: string,
|
||||
transcriptHash: string,
|
||||
promptHash: string,
|
||||
transcript: string
|
||||
): Promise<void> => {
|
||||
const bucketName = env.fileUpload.gcsUploadBucket
|
||||
|
||||
try {
|
||||
await writeStringToStorage(
|
||||
bucketName,
|
||||
`youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html`,
|
||||
transcript
|
||||
)
|
||||
} catch (err) {
|
||||
logger.info(`unable to cache transcript`, { error: err })
|
||||
}
|
||||
}
|
||||
|
||||
export const processYouTubeVideo = async (
|
||||
jobData: ProcessYouTubeVideoJobData
|
||||
) => {
|
||||
try {
|
||||
const libraryItem = await authTrx(
|
||||
async (tx) =>
|
||||
tx
|
||||
.withRepository(libraryItemRepository)
|
||||
.findById(jobData.libraryItemId),
|
||||
undefined,
|
||||
jobData.userId
|
||||
)
|
||||
if (
|
||||
!libraryItem ||
|
||||
libraryItem.state !== LibraryItemState.Succeeded ||
|
||||
!libraryItem.originalContent
|
||||
) {
|
||||
logger.info(
|
||||
`Not ready to get YouTube metadata job state: ${
|
||||
libraryItem?.state ?? 'null'
|
||||
}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const u = new URL(libraryItem.originalUrl)
|
||||
const videoId = u.searchParams.get('v')
|
||||
|
||||
if (!videoId) {
|
||||
console.warn('no video id for supplied youtube url', {
|
||||
url: libraryItem.originalUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let needsUpdate = false
|
||||
const youtube = new YouTubeClient()
|
||||
const video = await youtube.getVideo(videoId)
|
||||
if (!video) {
|
||||
console.warn('no video found for youtube url', {
|
||||
url: libraryItem.originalUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (video.description && libraryItem.description !== video.description) {
|
||||
needsUpdate = true
|
||||
libraryItem.description = video.description
|
||||
}
|
||||
|
||||
let duration = -1
|
||||
if ('duration' in video && video.duration > 0) {
|
||||
needsUpdate = true
|
||||
libraryItem.wordCount = calculateWordCount(video.duration)
|
||||
duration = video.duration
|
||||
}
|
||||
|
||||
if (
|
||||
await findFeatureByName(FeatureName.YouTubeTranscripts, jobData.userId)
|
||||
) {
|
||||
if ('getTranscript' in video && duration > 0 && duration < 1801) {
|
||||
// If the video has a transcript available, put a placehold in and
|
||||
// enqueue a job to process the full transcript
|
||||
const updatedContent = await addTranscriptPlaceholdReadableContent(
|
||||
libraryItem.originalUrl,
|
||||
libraryItem.originalContent
|
||||
)
|
||||
|
||||
if (updatedContent) {
|
||||
needsUpdate = true
|
||||
libraryItem.readableContent = updatedContent
|
||||
}
|
||||
|
||||
await enqueueProcessYouTubeTranscript({
|
||||
videoId,
|
||||
...jobData,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const updated = await authTrx(
|
||||
async (t) => {
|
||||
return t
|
||||
.getRepository(LibraryItem)
|
||||
.update(jobData.libraryItemId, libraryItem)
|
||||
},
|
||||
undefined,
|
||||
jobData.userId
|
||||
)
|
||||
if (!updated) {
|
||||
console.warn('could not updated library item')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('error creating summary: ', err)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProcessYouTubeTranscriptJobData {
|
||||
userId: string
|
||||
videoId: string
|
||||
libraryItemId: string
|
||||
}
|
||||
|
||||
export const processYouTubeTranscript = async (
|
||||
jobData: ProcessYouTubeTranscriptJobData
|
||||
) => {
|
||||
try {
|
||||
const libraryItem = await authTrx(
|
||||
async (tx) =>
|
||||
tx
|
||||
.withRepository(libraryItemRepository)
|
||||
.findById(jobData.libraryItemId),
|
||||
undefined,
|
||||
jobData.userId
|
||||
)
|
||||
if (
|
||||
!libraryItem ||
|
||||
libraryItem.state !== LibraryItemState.Succeeded ||
|
||||
!libraryItem.originalContent
|
||||
) {
|
||||
logger.info(
|
||||
`Not ready to get YouTube metadata job state: ${
|
||||
libraryItem?.state ?? 'null'
|
||||
}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
let needsUpdate = false
|
||||
const youtube = new YouTubeClient()
|
||||
const video = await youtube.getVideo(jobData.videoId)
|
||||
if (!video) {
|
||||
logger.warn('no video found for youtube url', {
|
||||
url: libraryItem.originalUrl,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let chapters: Chapter[] = []
|
||||
if ('chapters' in video) {
|
||||
chapters = video.chapters
|
||||
}
|
||||
|
||||
let transcript: TranscriptProperties[] | undefined = undefined
|
||||
if ('getTranscript' in video) {
|
||||
transcript = await video.getTranscript()
|
||||
}
|
||||
|
||||
if (transcript) {
|
||||
if (chapters) {
|
||||
transcript = addTranscriptChapters(chapters, transcript)
|
||||
}
|
||||
const transcriptHTML = await createTranscriptHTML(
|
||||
jobData.videoId,
|
||||
transcript
|
||||
)
|
||||
const updatedContent = await addTranscriptToReadableContent(
|
||||
libraryItem.originalUrl,
|
||||
libraryItem.originalContent,
|
||||
transcriptHTML
|
||||
)
|
||||
|
||||
if (updatedContent) {
|
||||
needsUpdate = true
|
||||
libraryItem.readableContent = updatedContent
|
||||
}
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
const updated = await authTrx(
|
||||
async (t) => {
|
||||
return t
|
||||
.getRepository(LibraryItem)
|
||||
.update(jobData.libraryItemId, libraryItem)
|
||||
},
|
||||
undefined,
|
||||
jobData.userId
|
||||
)
|
||||
if (!updated) {
|
||||
console.warn('could not updated library item')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('error creating summary: ', err)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { Merge } from './util'
|
|||
import {
|
||||
enqueueAISummarizeJob,
|
||||
enqueueExportItem,
|
||||
enqueueProcessYouTubeVideo,
|
||||
enqueueTriggerRuleJob,
|
||||
enqueueWebhookJob,
|
||||
} from './utils/createTask'
|
||||
|
|
@ -17,6 +18,7 @@ import {
|
|||
findFeatureByName,
|
||||
getFeatureName,
|
||||
} from './services/features'
|
||||
import { processYouTubeVideo } from './jobs/process-youtube-video'
|
||||
|
||||
const logger = buildLogger('pubsub')
|
||||
|
||||
|
|
@ -24,6 +26,18 @@ const client = new PubSub()
|
|||
|
||||
type EntityData<T> = Merge<T, { libraryItemId: string }>
|
||||
|
||||
const isYouTubeVideoURL = (url: string | undefined): boolean => {
|
||||
if (!url) {
|
||||
return false
|
||||
}
|
||||
const u = new URL(url)
|
||||
if (!u.host.endsWith('youtube.com') && !u.host.endsWith('youtu.be')) {
|
||||
return false
|
||||
}
|
||||
const videoId = u.searchParams.get('v')
|
||||
return videoId != null
|
||||
}
|
||||
|
||||
export const createPubSubClient = (): PubsubClient => {
|
||||
const fieldsToDelete = ['user'] as const
|
||||
|
||||
|
|
@ -89,7 +103,17 @@ export const createPubSubClient = (): PubsubClient => {
|
|||
})
|
||||
|
||||
if (await findFeatureByName(FeatureName.AISummaries, userId)) {
|
||||
await enqueueAISummarizeJob({
|
||||
// await enqueueAISummarizeJob({
|
||||
// userId,
|
||||
// libraryItemId,
|
||||
// })
|
||||
}
|
||||
|
||||
if (
|
||||
'originalUrl' in data &&
|
||||
isYouTubeVideoURL(data['originalUrl'] as string | undefined)
|
||||
) {
|
||||
await enqueueProcessYouTubeVideo({
|
||||
userId,
|
||||
libraryItemId,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ import { redisDataSource } from './redis_data_source'
|
|||
import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_position'
|
||||
import { getJobPriority } from './utils/createTask'
|
||||
import { logger } from './utils/logger'
|
||||
import {
|
||||
PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME,
|
||||
PROCESS_YOUTUBE_VIDEO_JOB_NAME,
|
||||
processYouTubeTranscript,
|
||||
processYouTubeVideo,
|
||||
} from './jobs/process-youtube-video'
|
||||
|
||||
export const QUEUE_NAME = 'omnivore-backend-queue'
|
||||
export const JOB_VERSION = 'v001'
|
||||
|
|
@ -116,8 +122,14 @@ export const createWorker = (connection: ConnectionOptions) =>
|
|||
return exportItem(job.data)
|
||||
case AI_SUMMARIZE_JOB_NAME:
|
||||
return aiSummarize(job.data)
|
||||
case PROCESS_YOUTUBE_VIDEO_JOB_NAME:
|
||||
return processYouTubeVideo(job.data)
|
||||
case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME:
|
||||
return processYouTubeTranscript(job.data)
|
||||
case EXPORT_ALL_ITEMS_JOB_NAME:
|
||||
return exportAllItems(job.data)
|
||||
default:
|
||||
logger.warn(`[queue-processor] unhandled job: ${job.name}`)
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { logger } from '../utils/logger'
|
|||
|
||||
export enum FeatureName {
|
||||
AISummaries = 'ai-summaries',
|
||||
YouTubeTranscripts = 'youtube-transcripts',
|
||||
UltraRealisticVoice = 'ultra-realistic-voice',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ import { stringToHash } from './helpers'
|
|||
import { logger } from './logger'
|
||||
import View = google.cloud.tasks.v2.Task.View
|
||||
import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize'
|
||||
import {
|
||||
PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME,
|
||||
PROCESS_YOUTUBE_VIDEO_JOB_NAME,
|
||||
ProcessYouTubeTranscriptJobData,
|
||||
ProcessYouTubeVideoJobData,
|
||||
} from '../jobs/process-youtube-video'
|
||||
|
||||
// Instantiates a client.
|
||||
const client = new CloudTasksClient()
|
||||
|
|
@ -67,10 +73,13 @@ export const getJobPriority = (jobName: string): number => {
|
|||
case TRIGGER_RULE_JOB_NAME:
|
||||
case CALL_WEBHOOK_JOB_NAME:
|
||||
case AI_SUMMARIZE_JOB_NAME:
|
||||
case PROCESS_YOUTUBE_VIDEO_JOB_NAME:
|
||||
return 5
|
||||
case BULK_ACTION_JOB_NAME:
|
||||
case `${REFRESH_FEED_JOB_NAME}_high`:
|
||||
return 10
|
||||
case PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME:
|
||||
return 20
|
||||
case `${REFRESH_FEED_JOB_NAME}_low`:
|
||||
case EXPORT_ITEM_JOB_NAME:
|
||||
return 50
|
||||
|
|
@ -78,6 +87,7 @@ export const getJobPriority = (jobName: string): number => {
|
|||
case REFRESH_ALL_FEEDS_JOB_NAME:
|
||||
case THUMBNAIL_JOB:
|
||||
return 100
|
||||
|
||||
default:
|
||||
logger.error(`unknown job name: ${jobName}`)
|
||||
return 1
|
||||
|
|
@ -708,6 +718,36 @@ export const enqueueAISummarizeJob = async (data: AISummarizeJobData) => {
|
|||
})
|
||||
}
|
||||
|
||||
export const enqueueProcessYouTubeVideo = async (
|
||||
data: ProcessYouTubeVideoJobData
|
||||
) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return queue.add(PROCESS_YOUTUBE_VIDEO_JOB_NAME, data, {
|
||||
priority: getJobPriority(PROCESS_YOUTUBE_VIDEO_JOB_NAME),
|
||||
attempts: 3,
|
||||
delay: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export const enqueueProcessYouTubeTranscript = async (
|
||||
data: ProcessYouTubeTranscriptJobData
|
||||
) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return queue.add(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME, data, {
|
||||
priority: getJobPriority(PROCESS_YOUTUBE_TRANSCRIPT_JOB_NAME),
|
||||
attempts: 3,
|
||||
delay: 2000,
|
||||
})
|
||||
}
|
||||
|
||||
export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => {
|
||||
const queue = await getBackendQueue()
|
||||
if (!queue) {
|
||||
|
|
|
|||
101
packages/api/test/jobs/process-youtube-job.test.ts
Normal file
101
packages/api/test/jobs/process-youtube-job.test.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { expect } from 'chai'
|
||||
import 'mocha'
|
||||
import { addTranscriptChapters } from '../../src/jobs/process-youtube-video'
|
||||
|
||||
describe('create transcript', () => {
|
||||
describe('build items', () => {
|
||||
it('properly adds chapter headers to transcript', async () => {
|
||||
const chapters = [
|
||||
{
|
||||
title: 'Intro',
|
||||
start: 0,
|
||||
},
|
||||
{
|
||||
title: "Joe Biden's re-election effort",
|
||||
start: 22000,
|
||||
},
|
||||
{
|
||||
title: 'Ad break',
|
||||
start: 909000,
|
||||
},
|
||||
{
|
||||
title: "Trump's crazy speech & Orbán relationship",
|
||||
start: 1060000,
|
||||
},
|
||||
]
|
||||
const transcript = [
|
||||
{
|
||||
text: "welcome to pod save America I'm John",
|
||||
duration: 3280,
|
||||
start: 80,
|
||||
},
|
||||
{
|
||||
text: "favro I'm John L I'm Tommy VOR on",
|
||||
duration: 3480,
|
||||
start: 1480,
|
||||
},
|
||||
{
|
||||
text: "today's show Donald Trump kicks off the",
|
||||
duration: 3320,
|
||||
start: 3360,
|
||||
},
|
||||
{
|
||||
text: 'general election by mocking Joe Biden',
|
||||
duration: 3400,
|
||||
start: 4960,
|
||||
},
|
||||
{
|
||||
text: 'stutter hosting a concert for Victor',
|
||||
duration: 3680,
|
||||
start: 6680,
|
||||
},
|
||||
{
|
||||
text: 'Orban and floating cuts to Medicare and',
|
||||
duration: 4239,
|
||||
start: 8360,
|
||||
},
|
||||
{
|
||||
text: 'Social Security Alabama Senator Katie',
|
||||
duration: 3840,
|
||||
start: 10360,
|
||||
},
|
||||
{
|
||||
text: 'Brit and Republicans are still dealing',
|
||||
duration: 3401,
|
||||
start: 12599,
|
||||
},
|
||||
{
|
||||
text: 'with the Fallout from what may have been',
|
||||
duration: 3320,
|
||||
start: 14200,
|
||||
},
|
||||
{
|
||||
text: 'the worst ever State of the Union',
|
||||
duration: 4600,
|
||||
start: 16000,
|
||||
},
|
||||
{
|
||||
text: 'response and later take appreciator is',
|
||||
duration: 6640,
|
||||
start: 17520,
|
||||
},
|
||||
{
|
||||
text: 'back so is Elijah uh but first the man',
|
||||
duration: 6519,
|
||||
start: 20600,
|
||||
},
|
||||
{
|
||||
text: 'Sean Hannity now calls jacked up Joe has',
|
||||
duration: 4680,
|
||||
start: 24160,
|
||||
},
|
||||
]
|
||||
|
||||
const res = addTranscriptChapters(chapters, transcript)
|
||||
console.log('res: ', res)
|
||||
|
||||
expect(res.length).to.eq(17)
|
||||
expect(res[13].text).to.eq("\n\n## Joe Biden's re-election effort\n\n")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -640,6 +640,7 @@ describe('Article API', () => {
|
|||
context('when the source is rss-feeder and url is from youtube.com', () => {
|
||||
const source = 'rss-feeder'
|
||||
const stub = sinon.stub(createTask, 'enqueueParseRequest')
|
||||
const stub2 = sinon.stub(createTask, 'enqueueProcessYouTubeVideo')
|
||||
|
||||
before(() => {
|
||||
url = 'https://www.youtube.com/watch?v=123'
|
||||
|
|
|
|||
|
|
@ -86,9 +86,14 @@ export class YoutubeHandler extends ContentHandler {
|
|||
<meta property="og:type" content="video" />
|
||||
</head>
|
||||
<body>
|
||||
<iframe width="${width}" height="${height}" src="${src}" title="${escapedTitle}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
<p><a href="${url}" target="_blank">${escapedTitle}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
|
||||
<div>
|
||||
<article id="_omnivore_youtube">
|
||||
<iframe id="_omnivore_youtube_video" width="${width}" height="${height}" src="${src}" title="${escapedTitle}" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
<p><a href="${url}" target="_blank">${escapedTitle}</a></p>
|
||||
<p itemscope="" itemprop="author" itemtype="http://schema.org/Person">By <a href="${oembed.author_url}" target="_blank">${authorName}</a></p>
|
||||
<div id="_omnivore_youtube_transcript"></div>
|
||||
</article>
|
||||
</div>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
|
|
|
|||
|
|
@ -115,6 +115,28 @@ export function Article(props: ArticleProps): JSX.Element {
|
|||
}
|
||||
}, 2500)
|
||||
|
||||
useEffect(() => {
|
||||
const youtubePlayer = document.getElementById('_omnivore_youtube_video')
|
||||
|
||||
const updateScroll = () => {
|
||||
console.log('scroll y: ', window.scrollY, youtubePlayer)
|
||||
|
||||
if (youtubePlayer) {
|
||||
if (window.scrollY > 200) {
|
||||
youtubePlayer.classList.add('is-sticky')
|
||||
} else {
|
||||
youtubePlayer.classList.remove('is-sticky')
|
||||
}
|
||||
}
|
||||
}
|
||||
if (youtubePlayer) {
|
||||
window.addEventListener('scroll', updateScroll)
|
||||
}
|
||||
return () => {
|
||||
window.removeEventListener('scroll', updateScroll) // clean up
|
||||
}
|
||||
}, [props])
|
||||
|
||||
// Scroll to initial anchor position
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
|
|
|
|||
|
|
@ -610,3 +610,58 @@
|
|||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.is-sticky {
|
||||
position: fixed;
|
||||
right: 5px;
|
||||
bottom: 5px;
|
||||
top: auto;
|
||||
left: auto;
|
||||
z-index: 10;
|
||||
max-width: 400px;
|
||||
max-height: 222px;
|
||||
width: 400px;
|
||||
height: 222px;
|
||||
animation-name: fadeInUp;
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
-webkit-animation-name: fadeInUp;
|
||||
-webkit-animation-duration: 0.5s;
|
||||
-webkit-animation-fill-mode: both;
|
||||
overflow: hidden;
|
||||
box-shadow: 0px 4px 4px rgba(33, 33, 33, 0.1) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.is-sticky {
|
||||
max-width: 200px;
|
||||
max-height: 110px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, 100%, 0);
|
||||
transform: translate3d(0, 100%, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@-webkit-keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
-webkit-transform: translate3d(0, 100%, 0);
|
||||
transform: translate3d(0, 100%, 0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
-webkit-transform: none;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
41
yarn.lock
41
yarn.lock
|
|
@ -2428,6 +2428,11 @@
|
|||
dependencies:
|
||||
text-decoding "^1.0.0"
|
||||
|
||||
"@fastify/busboy@^2.0.0":
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/@fastify/busboy/-/busboy-2.1.1.tgz#b9da6a878a371829a0502c9b6c1c143ef6663f4d"
|
||||
integrity sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==
|
||||
|
||||
"@ffmpeg-installer/darwin-arm64@4.1.5":
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@ffmpeg-installer/darwin-arm64/-/darwin-arm64-4.1.5.tgz#b7b5c262dd96d1aea4807514e1cdcf6e11f82743"
|
||||
|
|
@ -8173,6 +8178,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.1.tgz#24134738ba3107237d6a783e054a54773e739f81"
|
||||
integrity sha512-xdnAw2nFqomkaL0QdtEk0t7yz26UkaVPl4v1pYJvtE1T0fmfQEH3JaxErEhGByEAl3zUZrkNBlneuJp0WJGqEA==
|
||||
|
||||
"@types/showdown@^2.0.6":
|
||||
version "2.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@types/showdown/-/showdown-2.0.6.tgz#3d7affd5f971b4a17783ec2b23b4ad3b97477b7e"
|
||||
integrity sha512-pTvD/0CIeqe4x23+YJWlX2gArHa8G0J0Oh6GKaVXV7TAeickpkkZiNOgFcFcmLQ5lB/K0qBJL1FtRYltBfbGCQ==
|
||||
|
||||
"@types/sinon-chai@^3.2.8":
|
||||
version "3.2.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/sinon-chai/-/sinon-chai-3.2.8.tgz#5871d09ab50d671d8e6dd72e9073f8e738ac61dc"
|
||||
|
|
@ -19286,6 +19296,13 @@ jest@^27.4.5:
|
|||
import-local "^3.0.2"
|
||||
jest-cli "^27.5.1"
|
||||
|
||||
jintr@^1.1.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/jintr/-/jintr-1.1.0.tgz#223a3b07f5e03d410cec6e715c537c8ad1e714c3"
|
||||
integrity sha512-Tu9wk3BpN2v+kb8yT6YBtue+/nbjeLFv4vvVC4PJ7oCidHKbifWhvORrAbQfxVIQZG+67am/mDagpiGSVtvrZg==
|
||||
dependencies:
|
||||
acorn "^8.8.0"
|
||||
|
||||
jose@^2.0.5:
|
||||
version "2.0.7"
|
||||
resolved "https://registry.yarnpkg.com/jose/-/jose-2.0.7.tgz#3aabbaec70bff313c108b9406498a163737b16ba"
|
||||
|
|
@ -29876,6 +29893,13 @@ undici@^4.9.3:
|
|||
resolved "https://registry.yarnpkg.com/undici/-/undici-4.14.1.tgz#7633b143a8a10d6d63335e00511d071e8d52a1d9"
|
||||
integrity sha512-WJ+g+XqiZcATcBaUeluCajqy4pEDcQfK1vy+Fo+bC4/mqXI9IIQD/XWHLS70fkGUT6P52Drm7IFslO651OdLPQ==
|
||||
|
||||
undici@^5.19.1:
|
||||
version "5.28.3"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-5.28.3.tgz#a731e0eff2c3fcfd41c1169a869062be222d1e5b"
|
||||
integrity sha512-3ItfzbrhDlINjaP0duwnNsKpDQk3acHI3gVJ1z4fmwMK31k5G9OVIAMLSIaP6w4FaGkaAkN6zaQO9LUvZ1t7VA==
|
||||
dependencies:
|
||||
"@fastify/busboy" "^2.0.0"
|
||||
|
||||
unfetch@^4.2.0:
|
||||
version "4.2.0"
|
||||
resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be"
|
||||
|
|
@ -31536,6 +31560,23 @@ yocto-queue@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251"
|
||||
integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==
|
||||
|
||||
youtubei.js@^9.1.0:
|
||||
version "9.1.0"
|
||||
resolved "https://registry.yarnpkg.com/youtubei.js/-/youtubei.js-9.1.0.tgz#bcf154c9fa21d3c8c1d00a5e10360d0a065c660e"
|
||||
integrity sha512-C5GBJ4LgnS6vGAUkdIdQNOFFb5EZ1p3xBvUELNXmIG3Idr6vxWrKNBNy8ClZT3SuDVXaAJqDgF9b5jvY8lNKcg==
|
||||
dependencies:
|
||||
jintr "^1.1.0"
|
||||
tslib "^2.5.0"
|
||||
undici "^5.19.1"
|
||||
|
||||
youtubei@^1.3.4:
|
||||
version "1.3.4"
|
||||
resolved "https://registry.yarnpkg.com/youtubei/-/youtubei-1.3.4.tgz#b9761e33dcc6e0a9569e6628ba1fc48c729636f0"
|
||||
integrity sha512-xN6p2oddcTpreF/ojU2mChwdiUlV+TwwUL6xgP6lXRuxeGS5MokM1tzRdXCgIpxkzYYNNAWpt7xvPuAUQM0PCg==
|
||||
dependencies:
|
||||
node-fetch "2.6.7"
|
||||
protobufjs "7.2.4"
|
||||
|
||||
yup@^0.31.0:
|
||||
version "0.31.1"
|
||||
resolved "https://registry.yarnpkg.com/yup/-/yup-0.31.1.tgz#0954cb181161f397b804346037a04f8a4b31599e"
|
||||
|
|
|
|||
Loading…
Reference in a new issue