From f7225b298a46e90fcac8eeb69f5fdadfd72d9b56 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Mar 2024 21:36:50 +0800 Subject: [PATCH 01/19] Rebase --- packages/api/package.json | 6 +- packages/api/src/jobs/get-youtube-info.ts | 153 ++++++++++++++++++ packages/api/src/pubsub.ts | 8 +- packages/api/src/queue-processor.ts | 6 + packages/api/src/utils/createTask.ts | 20 +++ .../src/websites/youtube-handler.ts | 7 +- 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 packages/api/src/jobs/get-youtube-info.ts diff --git a/packages/api/package.json b/packages/api/package.json index b387616f4..45ad5c0c9 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -97,6 +97,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 +108,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 +139,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", diff --git a/packages/api/src/jobs/get-youtube-info.ts b/packages/api/src/jobs/get-youtube-info.ts new file mode 100644 index 000000000..9f7abbdf2 --- /dev/null +++ b/packages/api/src/jobs/get-youtube-info.ts @@ -0,0 +1,153 @@ +import { logger } from '../utils/logger' +import { loadSummarizationChain } from 'langchain/chains' +import { ChatOpenAI } from '@langchain/openai' +import { + CharacterTextSplitter, + RecursiveCharacterTextSplitter, +} from 'langchain/text_splitter' +import { DocumentInterface } from '@langchain/core/documents' +import { YoutubeLoader } from 'langchain/document_loaders/web/youtube' +import { authTrx } from '../repository' +import { libraryItemRepository } from '../repository/library_item' +import { htmlToMarkdown, parsePreparedContent } from '../utils/parser' +import { AISummary } from '../entity/AISummary' +import { LibraryItem, LibraryItemState } from '../entity/library_item' +import { getAISummary } from '../services/ai-summaries' +import { YoutubeTranscript, TranscriptResponse } from 'youtube-transcript' +import { Converter } from 'showdown' +import { Video, Client as YouTubeClient } from 'youtubei' + +export interface ProcessYouTubeVideoJobData { + userId: string + libraryItemId: string +} + +export const PROCESS_YOU_TUBE_VIDEO_JOB_NAME = 'process-you-tube-video' + +export const processYouTubeVideo = async ( + jobData: ProcessYouTubeVideoJobData +) => { + try { + console.log( + '******************************* processYouTubeVideo *************************' + ) + const libraryItem = await authTrx( + async (tx) => + tx + .withRepository(libraryItemRepository) + .findById(jobData.libraryItemId), + undefined, + jobData.userId + ) + if (!libraryItem || libraryItem.state !== LibraryItemState.Succeeded) { + logger.info( + `Not ready to get YouTube metadata job state: ${ + libraryItem?.state ?? 'null' + }` + ) + return + } + + // const doc = await YoutubeLoader.createFromUrl(libraryItem.originalUrl, { + // language: 'en', + // addVideoInfo: true, + // }).load() + + // console.log('doc from youtube:', doc) + + const youtube = new YouTubeClient() + const video = (await youtube.getVideo( + 'Y0fqyJUrwe0' /* libraryItem.originalUrl */ + )) as Video + console.log('GOT VIDEO: ', video) + const transcript = await video.getTranscript() + + console.log('description: ', video?.description) + console.log('chapters: ', video?.chapters) + + // const transcript = await YoutubeTranscript.fetchTranscript( + // libraryItem.originalUrl + // ) + + if (transcript) { + console.log( + 'original transcript:\n', + transcript.map((item) => item.text).join(' '), + '\n\n' + ) + } else { + console.log('no transcript found') + } + + // const prompt = `Given the following transcript data, supplied as a list of text segments, turn it into readable + // text adding punctuation and paragraphs. Format the output as markdown. + + // ${JSON.stringify(transcript).replace(/"/g, '\\"')} + // ` + + // const llm = new ChatOpenAI({ + // configuration: { + // apiKey: process.env.OPENAI_API_KEY, + // }, + // }) + // const response = await llm.generate([[prompt]]) + // console.log('response: ', response.generations, response.llmOutput) + + // const text = response.generations[0][0].text + // const converter = new Converter() + // const transcriptHTML = converter.makeHtml(text) + + // const html = ` + // 1 Billion Rows Challenge + // + // + // + // + // + // + // + // + // + //
+ //

+ // + //

+ // 1 Billion Rows Challenge

+ // + //

+ //
+ //
+ // + // `.replace( + // '
', + // `
${transcriptHTML}
` + // ) + + // console.log('input HTML: ', html) + // if (html) { + // const preparedDocument = { + // document: html, + // pageInfo: {}, + // } + // const updatedContent = await parsePreparedContent( + // libraryItem.originalUrl, + // preparedDocument, + // true + // ) + // console.log('updated content: ', updatedContent.parsedContent?.content) + // libraryItem.readableContent = + // updatedContent.parsedContent?.content ?? libraryItem.readableContent + // const _ = await authTrx( + // async (t) => { + // return t + // .getRepository(LibraryItem) + // .update(jobData.libraryItemId, libraryItem) + // }, + // undefined, + // jobData.userId + // ) + // } + } catch (err) { + console.log('error creating summary: ', err) + } +} diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 7bf25d921..5d2b21817 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -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/get-youtube-info' const logger = buildLogger('pubsub') @@ -89,7 +91,11 @@ export const createPubSubClient = (): PubsubClient => { }) if (await findFeatureByName(FeatureName.AISummaries, userId)) { - await enqueueAISummarizeJob({ + // await enqueueAISummarizeJob({ + // userId, + // libraryItemId, + // }) + await enqueueProcessYouTubeVideo({ userId, libraryItemId, }) diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 959c866fc..7fc18f518 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -44,6 +44,10 @@ 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_YOU_TUBE_VIDEO_JOB_NAME, + processYouTubeVideo, +} from './jobs/get-youtube-info' export const QUEUE_NAME = 'omnivore-backend-queue' export const JOB_VERSION = 'v001' @@ -116,6 +120,8 @@ export const createWorker = (connection: ConnectionOptions) => return exportItem(job.data) case AI_SUMMARIZE_JOB_NAME: return aiSummarize(job.data) + case PROCESS_YOU_TUBE_VIDEO_JOB_NAME: + return processYouTubeVideo(job.data) case EXPORT_ALL_ITEMS_JOB_NAME: return exportAllItems(job.data) } diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 56e209091..6c9345f8f 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -45,6 +45,10 @@ 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_YOU_TUBE_VIDEO_JOB_NAME, + ProcessYouTubeVideoJobData, +} from '../jobs/get-youtube-info' // Instantiates a client. const client = new CloudTasksClient() @@ -78,6 +82,8 @@ export const getJobPriority = (jobName: string): number => { case REFRESH_ALL_FEEDS_JOB_NAME: case THUMBNAIL_JOB: return 100 + case PROCESS_YOU_TUBE_VIDEO_JOB_NAME: + return 20 default: logger.error(`unknown job name: ${jobName}`) return 1 @@ -708,6 +714,20 @@ export const enqueueAISummarizeJob = async (data: AISummarizeJobData) => { }) } +export const enqueueProcessYouTubeVideo = async ( + data: ProcessYouTubeVideoJobData +) => { + const queue = await getBackendQueue() + if (!queue) { + return undefined + } + + return queue.add(PROCESS_YOU_TUBE_VIDEO_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOU_TUBE_VIDEO_JOB_NAME), + attempts: 3, + }) +} + export const bulkEnqueueUpdateLabels = async (data: UpdateLabelsData[]) => { const queue = await getBackendQueue() if (!queue) { diff --git a/packages/content-handler/src/websites/youtube-handler.ts b/packages/content-handler/src/websites/youtube-handler.ts index e86eda113..33d24cf05 100644 --- a/packages/content-handler/src/websites/youtube-handler.ts +++ b/packages/content-handler/src/websites/youtube-handler.ts @@ -86,9 +86,10 @@ export class YoutubeHandler extends ContentHandler { - -

${escapedTitle}

- + +

${escapedTitle}

+ +
` From 2dbd16a61eaa26523ced24c3d4e496c6e6068cdb Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Mar 2024 17:48:16 +0800 Subject: [PATCH 02/19] Pull duration and description from YouTube metadata --- packages/api/src/jobs/get-youtube-info.ts | 153 ---------------------- packages/api/src/pubsub.ts | 20 ++- packages/api/src/queue-processor.ts | 6 +- packages/api/src/utils/createTask.ts | 10 +- yarn.lock | 41 ++++++ 5 files changed, 68 insertions(+), 162 deletions(-) delete mode 100644 packages/api/src/jobs/get-youtube-info.ts diff --git a/packages/api/src/jobs/get-youtube-info.ts b/packages/api/src/jobs/get-youtube-info.ts deleted file mode 100644 index 9f7abbdf2..000000000 --- a/packages/api/src/jobs/get-youtube-info.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { logger } from '../utils/logger' -import { loadSummarizationChain } from 'langchain/chains' -import { ChatOpenAI } from '@langchain/openai' -import { - CharacterTextSplitter, - RecursiveCharacterTextSplitter, -} from 'langchain/text_splitter' -import { DocumentInterface } from '@langchain/core/documents' -import { YoutubeLoader } from 'langchain/document_loaders/web/youtube' -import { authTrx } from '../repository' -import { libraryItemRepository } from '../repository/library_item' -import { htmlToMarkdown, parsePreparedContent } from '../utils/parser' -import { AISummary } from '../entity/AISummary' -import { LibraryItem, LibraryItemState } from '../entity/library_item' -import { getAISummary } from '../services/ai-summaries' -import { YoutubeTranscript, TranscriptResponse } from 'youtube-transcript' -import { Converter } from 'showdown' -import { Video, Client as YouTubeClient } from 'youtubei' - -export interface ProcessYouTubeVideoJobData { - userId: string - libraryItemId: string -} - -export const PROCESS_YOU_TUBE_VIDEO_JOB_NAME = 'process-you-tube-video' - -export const processYouTubeVideo = async ( - jobData: ProcessYouTubeVideoJobData -) => { - try { - console.log( - '******************************* processYouTubeVideo *************************' - ) - const libraryItem = await authTrx( - async (tx) => - tx - .withRepository(libraryItemRepository) - .findById(jobData.libraryItemId), - undefined, - jobData.userId - ) - if (!libraryItem || libraryItem.state !== LibraryItemState.Succeeded) { - logger.info( - `Not ready to get YouTube metadata job state: ${ - libraryItem?.state ?? 'null' - }` - ) - return - } - - // const doc = await YoutubeLoader.createFromUrl(libraryItem.originalUrl, { - // language: 'en', - // addVideoInfo: true, - // }).load() - - // console.log('doc from youtube:', doc) - - const youtube = new YouTubeClient() - const video = (await youtube.getVideo( - 'Y0fqyJUrwe0' /* libraryItem.originalUrl */ - )) as Video - console.log('GOT VIDEO: ', video) - const transcript = await video.getTranscript() - - console.log('description: ', video?.description) - console.log('chapters: ', video?.chapters) - - // const transcript = await YoutubeTranscript.fetchTranscript( - // libraryItem.originalUrl - // ) - - if (transcript) { - console.log( - 'original transcript:\n', - transcript.map((item) => item.text).join(' '), - '\n\n' - ) - } else { - console.log('no transcript found') - } - - // const prompt = `Given the following transcript data, supplied as a list of text segments, turn it into readable - // text adding punctuation and paragraphs. Format the output as markdown. - - // ${JSON.stringify(transcript).replace(/"/g, '\\"')} - // ` - - // const llm = new ChatOpenAI({ - // configuration: { - // apiKey: process.env.OPENAI_API_KEY, - // }, - // }) - // const response = await llm.generate([[prompt]]) - // console.log('response: ', response.generations, response.llmOutput) - - // const text = response.generations[0][0].text - // const converter = new Converter() - // const transcriptHTML = converter.makeHtml(text) - - // const html = ` - // 1 Billion Rows Challenge - // - // - // - // - // - // - // - // - // - // - // - // `.replace( - // '
', - // `
${transcriptHTML}
` - // ) - - // console.log('input HTML: ', html) - // if (html) { - // const preparedDocument = { - // document: html, - // pageInfo: {}, - // } - // const updatedContent = await parsePreparedContent( - // libraryItem.originalUrl, - // preparedDocument, - // true - // ) - // console.log('updated content: ', updatedContent.parsedContent?.content) - // libraryItem.readableContent = - // updatedContent.parsedContent?.content ?? libraryItem.readableContent - // const _ = await authTrx( - // async (t) => { - // return t - // .getRepository(LibraryItem) - // .update(jobData.libraryItemId, libraryItem) - // }, - // undefined, - // jobData.userId - // ) - // } - } catch (err) { - console.log('error creating summary: ', err) - } -} diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 5d2b21817..2d7f12ba0 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -18,7 +18,7 @@ import { findFeatureByName, getFeatureName, } from './services/features' -import { processYouTubeVideo } from './jobs/get-youtube-info' +import { processYouTubeVideo } from './jobs/process-youtube-video' const logger = buildLogger('pubsub') @@ -26,6 +26,18 @@ const client = new PubSub() type EntityData = Merge +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 @@ -95,6 +107,12 @@ export const createPubSubClient = (): PubsubClient => { // userId, // libraryItemId, // }) + } + + if ( + 'originalUrl' in data && + isYouTubeVideoURL(data['originalUrl'] as string | undefined) + ) { await enqueueProcessYouTubeVideo({ userId, libraryItemId, diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index 7fc18f518..ed7940879 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -45,9 +45,9 @@ import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_positi import { getJobPriority } from './utils/createTask' import { logger } from './utils/logger' import { - PROCESS_YOU_TUBE_VIDEO_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, processYouTubeVideo, -} from './jobs/get-youtube-info' +} from './jobs/process-youtube-video' export const QUEUE_NAME = 'omnivore-backend-queue' export const JOB_VERSION = 'v001' @@ -120,7 +120,7 @@ export const createWorker = (connection: ConnectionOptions) => return exportItem(job.data) case AI_SUMMARIZE_JOB_NAME: return aiSummarize(job.data) - case PROCESS_YOU_TUBE_VIDEO_JOB_NAME: + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: return processYouTubeVideo(job.data) case EXPORT_ALL_ITEMS_JOB_NAME: return exportAllItems(job.data) diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 6c9345f8f..fd99e1668 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -46,9 +46,9 @@ import { logger } from './logger' import View = google.cloud.tasks.v2.Task.View import { AISummarizeJobData, AI_SUMMARIZE_JOB_NAME } from '../jobs/ai-summarize' import { - PROCESS_YOU_TUBE_VIDEO_JOB_NAME, + PROCESS_YOUTUBE_VIDEO_JOB_NAME, ProcessYouTubeVideoJobData, -} from '../jobs/get-youtube-info' +} from '../jobs/process-youtube-video' // Instantiates a client. const client = new CloudTasksClient() @@ -82,7 +82,7 @@ export const getJobPriority = (jobName: string): number => { case REFRESH_ALL_FEEDS_JOB_NAME: case THUMBNAIL_JOB: return 100 - case PROCESS_YOU_TUBE_VIDEO_JOB_NAME: + case PROCESS_YOUTUBE_VIDEO_JOB_NAME: return 20 default: logger.error(`unknown job name: ${jobName}`) @@ -722,8 +722,8 @@ export const enqueueProcessYouTubeVideo = async ( return undefined } - return queue.add(PROCESS_YOU_TUBE_VIDEO_JOB_NAME, data, { - priority: getJobPriority(PROCESS_YOU_TUBE_VIDEO_JOB_NAME), + return queue.add(PROCESS_YOUTUBE_VIDEO_JOB_NAME, data, { + priority: getJobPriority(PROCESS_YOUTUBE_VIDEO_JOB_NAME), attempts: 3, }) } diff --git a/yarn.lock b/yarn.lock index cf307822f..192ec8f0f 100644 --- a/yarn.lock +++ b/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" From 7c3d15e31a219f255cc29669cc0ca0996d5f1a92 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Mar 2024 18:53:17 +0800 Subject: [PATCH 03/19] Add some scrolling on youtube videos --- .../api/src/jobs/process-youtube-video.ts | 88 +++++++++++++++++++ .../components/templates/article/Article.tsx | 22 +++++ packages/web/styles/articleInnerStyling.css | 28 ++++++ 3 files changed, 138 insertions(+) create mode 100644 packages/api/src/jobs/process-youtube-video.ts diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts new file mode 100644 index 000000000..82bf1f946 --- /dev/null +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -0,0 +1,88 @@ +import { logger } from '../utils/logger' +import { authTrx } from '../repository' +import { libraryItemRepository } from '../repository/library_item' +import { LibraryItem, LibraryItemState } from '../entity/library_item' + +import { Video, Client as YouTubeClient } from 'youtubei' + +export interface ProcessYouTubeVideoJobData { + userId: string + libraryItemId: string +} + +export const PROCESS_YOUTUBE_VIDEO_JOB_NAME = 'process-youtube-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 +} + +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) { + 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 + } + + if ('duration' in video && (video as Video).duration > 0) { + needsUpdate = true + libraryItem.wordCount = calculateWordCount((video as Video).duration) + } + + if (needsUpdate) { + const _ = await authTrx( + async (t) => { + return t + .getRepository(LibraryItem) + .update(jobData.libraryItemId, libraryItem) + }, + undefined, + jobData.userId + ) + } + } catch (err) { + console.log('error creating summary: ', err) + } +} diff --git a/packages/web/components/templates/article/Article.tsx b/packages/web/components/templates/article/Article.tsx index 5f7d48ba4..91ef82161 100644 --- a/packages/web/components/templates/article/Article.tsx +++ b/packages/web/components/templates/article/Article.tsx @@ -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') { diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index c38f290a7..b0a46dc39 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -610,3 +610,31 @@ white-space: pre-wrap; overflow-wrap: break-word; } + +.is-sticky { + position: fixed; + right: 5px; + bottom: 5px; + top: auto; + left: auto; + max-width: 400px; + max-height: 222px; + width: 400px; + height: 222px; + animation-name: fadeInUp; + animation-duration: 0.5s; + animation-fill-mode: both; +} + +@keyframes fadeInUp { + 0% { + opacity: 0; + -webkit-transform: translate3d(0, 100%, 0); + transform: translate3d(0, 100%, 0); + } + 100% { + opacity: 1; + -webkit-transform: none; + transform: none; + } +} \ No newline at end of file From 84f0d940d2b3682e82a7ddad15cdf872cb124008 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Tue, 12 Mar 2024 20:37:05 +0800 Subject: [PATCH 04/19] Add some box shadow on the sticky player --- packages/web/styles/articleInnerStyling.css | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index b0a46dc39..d76fa822e 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -617,6 +617,7 @@ bottom: 5px; top: auto; left: auto; + z-index: 10; max-width: 400px; max-height: 222px; width: 400px; @@ -624,6 +625,8 @@ animation-name: fadeInUp; animation-duration: 0.5s; animation-fill-mode: both; + overflow: hidden; + box-shadow: 0px 4px 4px rgba(33, 33, 33, 0.1) !important; } @keyframes fadeInUp { From 6bcdbfa8f019797ed015fbb5ea94ed2dfcb382ff Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 10:02:23 +0800 Subject: [PATCH 05/19] Linting --- packages/api/src/pubsub.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/pubsub.ts b/packages/api/src/pubsub.ts index 2d7f12ba0..9bc4858b1 100644 --- a/packages/api/src/pubsub.ts +++ b/packages/api/src/pubsub.ts @@ -26,7 +26,7 @@ const client = new PubSub() type EntityData = Merge -const isYouTubeVideoURL = (url: string | undefined): Boolean => { +const isYouTubeVideoURL = (url: string | undefined): boolean => { if (!url) { return false } From 39dfa920b5e42cb3c7c9a60ea8cb1188c6862960 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 10:02:53 +0800 Subject: [PATCH 06/19] More fixes to youtube processor --- .../api/src/jobs/process-youtube-video.ts | 69 +++++++++++++++++-- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 82bf1f946..3962c8078 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -3,7 +3,7 @@ import { authTrx } from '../repository' import { libraryItemRepository } from '../repository/library_item' import { LibraryItem, LibraryItemState } from '../entity/library_item' -import { Video, Client as YouTubeClient } from 'youtubei' +import { Chapter, Client as YouTubeClient } from 'youtubei' export interface ProcessYouTubeVideoJobData { userId: string @@ -20,6 +20,44 @@ const calculateWordCount = (durationInSeconds: number): number => { 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 = '## ' + 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 +} + export const processYouTubeVideo = async ( jobData: ProcessYouTubeVideoJobData ) => { @@ -66,13 +104,31 @@ export const processYouTubeVideo = async ( libraryItem.description = video.description } - if ('duration' in video && (video as Video).duration > 0) { + if ('duration' in video && video.duration > 0) { needsUpdate = true - libraryItem.wordCount = calculateWordCount((video as Video).duration) + libraryItem.wordCount = calculateWordCount(video.duration) + } + + let chapters: Chapter[] = [] + if ('chapters' in video) { + chapters = video.chapters + console.log('video.chapters: ', video.chapters) + } + + let transcript: TranscriptProperties[] | undefined = undefined + if ('getTranscript' in video) { + transcript = await video.getTranscript() + console.log('transcript: ', transcript) + } + + if (transcript) { + if (chapters) { + transcript = addTranscriptChapters(chapters, transcript) + } } if (needsUpdate) { - const _ = await authTrx( + const updated = await authTrx( async (t) => { return t .getRepository(LibraryItem) @@ -81,8 +137,11 @@ export const processYouTubeVideo = async ( undefined, jobData.userId ) + if (!updated) { + console.warn('could not updated library item') + } } } catch (err) { - console.log('error creating summary: ', err) + console.warn('error creating summary: ', err) } } From a7ad67b3bb8dcbdbc877cf60dee2c59594360125 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 10:13:19 +0800 Subject: [PATCH 07/19] Start to add tests for youtube processor --- .../api/test/jobs/process-youtube-job.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/api/test/jobs/process-youtube-job.test.ts diff --git a/packages/api/test/jobs/process-youtube-job.test.ts b/packages/api/test/jobs/process-youtube-job.test.ts new file mode 100644 index 000000000..70b8658a7 --- /dev/null +++ b/packages/api/test/jobs/process-youtube-job.test.ts @@ -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("## Joe Biden's re-election effort\n\n") + }) + }) +}) From 3ee6787e395121198e17a8f68ca6d39a288d5ff7 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 17:50:07 +0800 Subject: [PATCH 08/19] Improve transcript generation --- packages/api/package.json | 1 + .../api/src/jobs/process-youtube-video.ts | 112 +++++++++++++++++- packages/api/src/utils/createTask.ts | 1 + .../src/websites/youtube-handler.ts | 12 +- 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/packages/api/package.json b/packages/api/package.json index 45ad5c0c9..3de277a1c 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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", diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 3962c8078..44235298a 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -4,6 +4,11 @@ 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' export interface ProcessYouTubeVideoJobData { userId: string @@ -39,7 +44,7 @@ export const addTranscriptChapters = ( for (const chapter of chapters) { const startOffset = chapter.start - const title = '## ' + chapter.title + '\n\n' + const title = '\n\n## ' + chapter.title + '\n\n' const index = transcript.findIndex( (textItem) => textItem.start > startOffset @@ -58,6 +63,92 @@ export const addTranscriptChapters = ( return transcript } +export const createTranscriptHTML = async ( + transcript: TranscriptProperties[] +): Promise => { + let transcriptMarkdown = '' + if (process.env.YOUTUBE_TRANSCRIPT_PROMPT && process.env.OPENAI_API_KEY) { + const llm = new OpenAI({ + modelName: 'gpt-4', + configuration: { + apiKey: process.env.OPENAI_API_KEY, + }, + }) + const promptTemplate = PromptTemplate.fromTemplate( + `${process.env.YOUTUBE_TRANSCRIPT_PROMPT} + + Data: + {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(' ') + } + + var converter = new showdown.Converter() + return converter.makeHtml(transcriptMarkdown) +} + +export const addTranscriptToReadableContent = async ( + originalUrl: string, + originalHTML: string, + transcriptHTML: string +): Promise => { + 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 processYouTubeVideo = async ( jobData: ProcessYouTubeVideoJobData ) => { @@ -70,7 +161,11 @@ export const processYouTubeVideo = async ( undefined, jobData.userId ) - if (!libraryItem || libraryItem.state !== LibraryItemState.Succeeded) { + if ( + !libraryItem || + libraryItem.state !== LibraryItemState.Succeeded || + !libraryItem.originalContent + ) { logger.info( `Not ready to get YouTube metadata job state: ${ libraryItem?.state ?? 'null' @@ -112,19 +207,28 @@ export const processYouTubeVideo = async ( let chapters: Chapter[] = [] if ('chapters' in video) { chapters = video.chapters - console.log('video.chapters: ', video.chapters) } let transcript: TranscriptProperties[] | undefined = undefined if ('getTranscript' in video) { transcript = await video.getTranscript() - console.log('transcript: ', transcript) } if (transcript) { if (chapters) { transcript = addTranscriptChapters(chapters, transcript) } + const transcriptHTML = await createTranscriptHTML(transcript) + const updatedContent = await addTranscriptToReadableContent( + libraryItem.originalUrl, + libraryItem.originalContent, + transcriptHTML + ) + + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } } if (needsUpdate) { diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index fd99e1668..2f5060f72 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -725,6 +725,7 @@ export const enqueueProcessYouTubeVideo = async ( return queue.add(PROCESS_YOUTUBE_VIDEO_JOB_NAME, data, { priority: getJobPriority(PROCESS_YOUTUBE_VIDEO_JOB_NAME), attempts: 3, + delay: 2000, }) } diff --git a/packages/content-handler/src/websites/youtube-handler.ts b/packages/content-handler/src/websites/youtube-handler.ts index 33d24cf05..cd7f87c95 100644 --- a/packages/content-handler/src/websites/youtube-handler.ts +++ b/packages/content-handler/src/websites/youtube-handler.ts @@ -86,10 +86,14 @@ export class YoutubeHandler extends ContentHandler { - -

${escapedTitle}

- -
+
+ +
` From fef28d1c6a02669ad917b89d22db607d7a1a5d96 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 18:07:29 +0800 Subject: [PATCH 09/19] Linting fix --- packages/api/src/jobs/process-youtube-video.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 44235298a..6c9307dc2 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -114,7 +114,7 @@ export const createTranscriptHTML = async ( transcriptMarkdown = transcript.map((item) => item.text).join(' ') } - var converter = new showdown.Converter() + const converter = new showdown.Converter() return converter.makeHtml(transcriptMarkdown) } From e9b15ebb06de190964ada6205e2939bb58dfa3fa Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 18:24:43 +0800 Subject: [PATCH 10/19] Dont process youtube videos in this test --- packages/api/test/resolvers/article.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index b7b638d22..8d3891af6 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -49,6 +49,7 @@ import { saveLabelsInLibraryItem, } from '../db' import { generateFakeUuid, graphqlRequest, request } from '../util' +import { processYouTubeVideo } from '../../src/jobs/process-youtube-video' chai.use(chaiString) @@ -640,6 +641,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' From 05fe1cb87c05cbe1250b3dcca5fdb86a3ea4e1ab Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 20:19:13 +0800 Subject: [PATCH 11/19] Webkit support for slide in frame --- packages/web/styles/articleInnerStyling.css | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index d76fa822e..83f6adb3d 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -625,6 +625,9 @@ 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; } @@ -640,4 +643,17 @@ -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; + } } \ No newline at end of file From 566ac33401d475bf8cc59019f9a6dda99b3f0999 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 20:22:12 +0800 Subject: [PATCH 12/19] Better small screen support --- packages/web/styles/articleInnerStyling.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/web/styles/articleInnerStyling.css b/packages/web/styles/articleInnerStyling.css index 83f6adb3d..251da4bb7 100644 --- a/packages/web/styles/articleInnerStyling.css +++ b/packages/web/styles/articleInnerStyling.css @@ -632,6 +632,14 @@ 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; From 308b02fbb03a6b9abcec1e7307a37448d0e979e9 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 22:00:33 +0800 Subject: [PATCH 13/19] Add breaks to headers in test --- packages/api/test/jobs/process-youtube-job.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/test/jobs/process-youtube-job.test.ts b/packages/api/test/jobs/process-youtube-job.test.ts index 70b8658a7..849aca41c 100644 --- a/packages/api/test/jobs/process-youtube-job.test.ts +++ b/packages/api/test/jobs/process-youtube-job.test.ts @@ -95,7 +95,7 @@ describe('create transcript', () => { console.log('res: ', res) expect(res.length).to.eq(17) - expect(res[13].text).to.eq("## Joe Biden's re-election effort\n\n") + expect(res[13].text).to.eq("\n\n## Joe Biden's re-election effort\n\n") }) }) }) From d3d181c33e9dfc59f4c5781e94dff2ff96c63dfa Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Wed, 13 Mar 2024 22:00:51 +0800 Subject: [PATCH 14/19] Remove unneed import --- packages/api/test/resolvers/article.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/api/test/resolvers/article.test.ts b/packages/api/test/resolvers/article.test.ts index 8d3891af6..e5d05e6ae 100644 --- a/packages/api/test/resolvers/article.test.ts +++ b/packages/api/test/resolvers/article.test.ts @@ -49,7 +49,6 @@ import { saveLabelsInLibraryItem, } from '../db' import { generateFakeUuid, graphqlRequest, request } from '../util' -import { processYouTubeVideo } from '../../src/jobs/process-youtube-video' chai.use(chaiString) From 629c0442730180d76586c362b6292be72af02d61 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 14 Mar 2024 15:09:50 +0800 Subject: [PATCH 15/19] Queue the transcript processing as a separate job Handle YouTube in two steps, first get metadata then get the transcript. --- .../api/src/jobs/process-youtube-video.ts | 121 +++++++++++++++++- packages/api/src/queue-processor.ts | 6 + packages/api/src/utils/createTask.ts | 23 +++- 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 6c9307dc2..76bacf23f 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -9,6 +9,7 @@ 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' export interface ProcessYouTubeVideoJobData { userId: string @@ -16,6 +17,10 @@ export interface ProcessYouTubeVideoJobData { } 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 @@ -77,7 +82,6 @@ export const createTranscriptHTML = async ( const promptTemplate = PromptTemplate.fromTemplate( `${process.env.YOUTUBE_TRANSCRIPT_PROMPT} - Data: {transcriptData}` ) const chain = promptTemplate.pipe(llm) @@ -114,7 +118,9 @@ export const createTranscriptHTML = async ( transcriptMarkdown = transcript.map((item) => item.text).join(' ') } - const converter = new showdown.Converter() + const converter = new showdown.Converter({ + backslashEscapesHTMLTags: true, + }) return converter.makeHtml(transcriptMarkdown) } @@ -149,6 +155,36 @@ export const addTranscriptToReadableContent = async ( return updatedContent.parsedContent?.content } +export const addTranscriptPlaceholdReadableContent = async ( + originalUrl: string, + originalHTML: string +): Promise => { + 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 +} + export const processYouTubeVideo = async ( jobData: ProcessYouTubeVideoJobData ) => { @@ -199,9 +235,90 @@ export const processYouTubeVideo = async ( 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 ('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[] = [] diff --git a/packages/api/src/queue-processor.ts b/packages/api/src/queue-processor.ts index ed7940879..36bea979b 100644 --- a/packages/api/src/queue-processor.ts +++ b/packages/api/src/queue-processor.ts @@ -45,7 +45,9 @@ import { CACHED_READING_POSITION_PREFIX } from './services/cached_reading_positi 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' @@ -122,8 +124,12 @@ export const createWorker = (connection: ConnectionOptions) => 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}`) } }, { diff --git a/packages/api/src/utils/createTask.ts b/packages/api/src/utils/createTask.ts index 2f5060f72..6d6c30baf 100644 --- a/packages/api/src/utils/createTask.ts +++ b/packages/api/src/utils/createTask.ts @@ -46,7 +46,9 @@ 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' @@ -71,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 @@ -82,8 +87,7 @@ export const getJobPriority = (jobName: string): number => { case REFRESH_ALL_FEEDS_JOB_NAME: case THUMBNAIL_JOB: return 100 - case PROCESS_YOUTUBE_VIDEO_JOB_NAME: - return 20 + default: logger.error(`unknown job name: ${jobName}`) return 1 @@ -729,6 +733,21 @@ export const enqueueProcessYouTubeVideo = async ( }) } +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) { From dc6c047aec8b532ce371853064583aaec59aa12b Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 14 Mar 2024 16:12:06 +0800 Subject: [PATCH 16/19] Add GCS cache --- .../api/src/jobs/process-youtube-video.ts | 152 +++++++++++++++++- 1 file changed, 150 insertions(+), 2 deletions(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 76bacf23f..7c430fa86 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -10,6 +10,11 @@ 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' export interface ProcessYouTubeVideoJobData { userId: string @@ -68,11 +73,29 @@ export const addTranscriptChapters = ( 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 => { 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: { @@ -121,7 +144,18 @@ export const createTranscriptHTML = async ( const converter = new showdown.Converter({ backslashEscapesHTMLTags: true, }) - return converter.makeHtml(transcriptMarkdown) + 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 ( @@ -185,6 +219,117 @@ export const addTranscriptPlaceholdReadableContent = async ( return updatedContent.parsedContent?.content } +async function readStringFromStorage( + bucketName: string, + fileName: string +): Promise { + 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 => { + 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 => { + 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: ${err}`) + } + + return undefined +} + +const cacheYouTubeTranscript = async ( + videoId: string, + transcriptHash: string, + promptHash: string, + transcript: string +): Promise => { + const bucketName = env.fileUpload.gcsUploadBucket + + try { + await writeStringToStorage( + bucketName, + `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html`, + transcript + ) + } catch (err) { + logger.info(`unable to cache transcript: ${err}`) + } +} + export const processYouTubeVideo = async ( jobData: ProcessYouTubeVideoJobData ) => { @@ -335,7 +480,10 @@ export const processYouTubeTranscript = async ( if (chapters) { transcript = addTranscriptChapters(chapters, transcript) } - const transcriptHTML = await createTranscriptHTML(transcript) + const transcriptHTML = await createTranscriptHTML( + jobData.videoId, + transcript + ) const updatedContent = await addTranscriptToReadableContent( libraryItem.originalUrl, libraryItem.originalContent, From f2d23626a51f22f8b5981942e51106117efdc497 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Thu, 14 Mar 2024 17:39:13 +0800 Subject: [PATCH 17/19] Improve error logging / fix linting --- packages/api/src/jobs/process-youtube-video.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index 7c430fa86..b4e5f49e7 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -305,7 +305,7 @@ const fetchCachedYouTubeTranscript = async ( `youtube-transcripts/${videoId}/${transcriptHash}.${promptHash}.html` ) } catch (err) { - logger.info(`unable to fetch cached transcript: ${err}`) + logger.info(`unable to fetch cached transcript`, { error: err }) } return undefined @@ -326,7 +326,7 @@ const cacheYouTubeTranscript = async ( transcript ) } catch (err) { - logger.info(`unable to cache transcript: ${err}`) + logger.info(`unable to cache transcript`, { error: err }) } } From b87073b1fb0018a084d6a98c0bdf3a565fd89539 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 15 Mar 2024 20:25:44 +0800 Subject: [PATCH 18/19] Add youtube-transcripts beta feature --- packages/api/src/services/features.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/api/src/services/features.ts b/packages/api/src/services/features.ts index 699c0f66e..f362d6b56 100644 --- a/packages/api/src/services/features.ts +++ b/packages/api/src/services/features.ts @@ -8,6 +8,7 @@ import { logger } from '../utils/logger' export enum FeatureName { AISummaries = 'ai-summaries', + YouTubeTranscripts = 'youtube-transcripts', UltraRealisticVoice = 'ultra-realistic-voice', } From 596ab5a7aa0d7170bb420bb6035c1e0dfbb6abf5 Mon Sep 17 00:00:00 2001 From: Jackson Harper Date: Fri, 15 Mar 2024 20:28:02 +0800 Subject: [PATCH 19/19] Feature flag the transcripts --- .../api/src/jobs/process-youtube-video.ts | 35 +++++++++++-------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/api/src/jobs/process-youtube-video.ts b/packages/api/src/jobs/process-youtube-video.ts index b4e5f49e7..fa4edcc76 100644 --- a/packages/api/src/jobs/process-youtube-video.ts +++ b/packages/api/src/jobs/process-youtube-video.ts @@ -15,6 +15,7 @@ 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 @@ -387,23 +388,27 @@ export const processYouTubeVideo = async ( duration = video.duration } - 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 ( + 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 + if (updatedContent) { + needsUpdate = true + libraryItem.readableContent = updatedContent + } + + await enqueueProcessYouTubeTranscript({ + videoId, + ...jobData, + }) } - - await enqueueProcessYouTubeTranscript({ - videoId, - ...jobData, - }) } if (needsUpdate) {