├── .github └── workflows │ ├── deploy.yaml │ └── test.yaml ├── .gitignore ├── .vscode ├── extensions.json ├── settings.json └── tailwind.json ├── README.md ├── app ├── deps.ts ├── src │ ├── app-config.ts │ ├── failure.ts │ ├── fetch-youtube-data.ts │ ├── get-captions-summary.ts │ ├── get-youtube-captions.ts │ ├── get-youtube-video-summary.ts │ ├── result.ts │ └── summarize-captions-with-chat-gpt.ts └── test │ ├── common.ts │ ├── data │ ├── expectedCaptionsUrl.txt │ ├── successfulYoutubeCaptionsResponse.xml │ └── successfulYoutubeDataResponse.html │ ├── get-captions-summary.test.ts │ └── get-youtube-video-summary.test.ts ├── components ├── Button.tsx ├── ErrorPage.tsx ├── GoBackHomeButton.tsx ├── Header.tsx ├── Input.tsx ├── Label.tsx └── Summary.tsx ├── deno.json ├── deno.lock ├── dev.ts ├── fresh.config.ts ├── fresh.gen.ts ├── main.ts ├── routes ├── _404.tsx ├── _app.tsx ├── index.tsx └── summary │ └── youtube.tsx ├── run.ts ├── static └── styles.css ├── tailwind.config.ts └── test └── main-test.test.ts /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: Deploy 2 | on: 3 | push: 4 | branches: [main] 5 | 6 | jobs: 7 | deploy: 8 | name: Deploy 9 | runs-on: ubuntu-latest 10 | 11 | permissions: 12 | id-token: write # Needed for auth with Deno Deploy 13 | contents: read # Needed to clone the repository 14 | 15 | steps: 16 | - name: Clone repository 17 | uses: actions/checkout@v3 18 | 19 | - name: Install Deno 20 | uses: denoland/setup-deno@v2 21 | with: 22 | deno-version: v2.1.9 23 | 24 | - name: Check formatting 25 | run: "deno fmt --check" 26 | 27 | - name: Lint 28 | run: "deno lint" 29 | 30 | - name: Test 31 | run: "deno task test" 32 | 33 | - name: Build step 34 | run: "deno task build" 35 | 36 | - name: Upload to Deno Deploy 37 | uses: denoland/deployctl@v1 38 | with: 39 | project: "just-tell-me" 40 | entrypoint: "./main.ts" 41 | -------------------------------------------------------------------------------- /.github/workflows/test.yaml: -------------------------------------------------------------------------------- 1 | name: Test 2 | on: 3 | pull_request: 4 | branches: main 5 | 6 | jobs: 7 | test: 8 | name: Test 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - name: Clone repository 13 | uses: actions/checkout@v3 14 | 15 | - name: Install Deno 16 | uses: denoland/setup-deno@v1 17 | with: 18 | deno-version: v1.39.4 19 | 20 | - name: Check formatting 21 | run: "deno fmt --check" 22 | 23 | - name: Lint 24 | run: "deno lint" 25 | 26 | - name: Test 27 | run: "deno task test" 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | TODO.md 2 | 3 | # dotenv environment variable files 4 | .env 5 | .env.development.local 6 | .env.test.local 7 | .env.production.local 8 | .env.local 9 | 10 | # Fresh build directory 11 | _fresh/ 12 | # npm dependencies 13 | node_modules/ 14 | 15 | cov/ 16 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "denoland.vscode-deno", 4 | "bradlc.vscode-tailwindcss" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.lint": true, 4 | // "deno.unstable": true, 5 | "editor.defaultFormatter": "denoland.vscode-deno", 6 | "[typescriptreact]": { 7 | "editor.defaultFormatter": "denoland.vscode-deno" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "denoland.vscode-deno" 11 | }, 12 | "[javascriptreact]": { 13 | "editor.defaultFormatter": "denoland.vscode-deno" 14 | }, 15 | "[javascript]": { 16 | "editor.defaultFormatter": "denoland.vscode-deno" 17 | }, 18 | "css.customData": [ 19 | ".vscode/tailwind.json" 20 | ], 21 | "editor.formatOnSave": true 22 | } 23 | -------------------------------------------------------------------------------- /.vscode/tailwind.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1.1, 3 | "atDirectives": [ 4 | { 5 | "name": "@tailwind", 6 | "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", 7 | "references": [ 8 | { 9 | "name": "Tailwind Documentation", 10 | "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" 11 | } 12 | ] 13 | }, 14 | { 15 | "name": "@apply", 16 | "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", 17 | "references": [ 18 | { 19 | "name": "Tailwind Documentation", 20 | "url": "https://tailwindcss.com/docs/functions-and-directives#apply" 21 | } 22 | ] 23 | }, 24 | { 25 | "name": "@responsive", 26 | "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css @responsive {\n .alert { background-color: #E53E3E;\n }\n}\n```\n", 27 | "references": [ 28 | { 29 | "name": "Tailwind Documentation", 30 | "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" 31 | } 32 | ] 33 | }, 34 | { 35 | "name": "@screen", 36 | "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css @screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css @media (min-width: 640px) {\n /* ... */\n}\n```\n", 37 | "references": [ 38 | { 39 | "name": "Tailwind Documentation", 40 | "url": "https://tailwindcss.com/docs/functions-and-directives#screen" 41 | } 42 | ] 43 | }, 44 | { 45 | "name": "@variants", 46 | "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css @variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", 47 | "references": [ 48 | { 49 | "name": "Tailwind Documentation", 50 | "url": "https://tailwindcss.com/docs/functions-and-directives#variants" 51 | } 52 | ] 53 | } 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Just Tell Me! 2 | 3 | 4 | Made with Fresh 10 | 11 | 12 | Have you ever wasted some time watching a youtube video, that got you kind of 13 | interested because of the click-baity topic, but in the end turned out to be 14 | nothing more BUT click-bait? Or have you ever wanted to just quickly recall what 15 | a video that you've watched some time ago was about? Just Tell Me has you 16 | covered! 17 | 18 | Just Tell Me is an app that summarizes youtube videos using ChatGPT. It uses the 19 | captions provided by youtube to ask ChatGPT to summarize the content. 20 | 21 | Check it out at https://just-tell-me.deno.dev/. 22 | 23 | The core of the app is written in Typescript and relies on 24 | [Deno](https://docs.deno.com/runtime/manual). The web app is built with 25 | [Fresh](https://fresh.deno.dev/) and deployed with 26 | [Deno Deploy](https://deno.com/deploy). 27 | 28 | ## How to run 29 | 30 | You can run the app from the CLI if you have `deno` installed: 31 | 32 | ``` 33 | deno run -A run.ts youtubeVideoId 34 | ``` 35 | 36 | The program relies on OpenAI API for ChatGPT and requires a `OPENAI_API_KEY` 37 | environment variable that contains a valid OpenAI API key. 38 | 39 | By default, the app uses the `gpt-4o-mini-2024-07-18` model, but you can also 40 | use other models, like `gpt-4` and `gpt-4-1106-preview`, by including a 41 | `--model=gpt-4` flag. All available models are listed in 42 | `app/src/summarize-captions-with-chat-gpt.ts`. 43 | 44 | Optionally, you can run the app in test mode (only `test` is considered a valid 45 | video id then) with: 46 | 47 | ``` 48 | TEST=true deno run -A run.ts test 49 | ``` 50 | 51 | To launch the web app locally, run: 52 | 53 | ``` 54 | deno task start 55 | ``` 56 | 57 | (You can also include `TEST=true` environment variable, to run the web app in 58 | test mode) 59 | -------------------------------------------------------------------------------- /app/deps.ts: -------------------------------------------------------------------------------- 1 | export { APIError, OpenAI } from "https://deno.land/x/openai@v4.27.0/mod.ts"; 2 | -------------------------------------------------------------------------------- /app/src/app-config.ts: -------------------------------------------------------------------------------- 1 | import { OpenAI } from "../deps.ts"; 2 | import { 3 | fetchYoutubeCaptions, 4 | fetchYoutubeVideoData, 5 | } from "./fetch-youtube-data.ts"; 6 | import { 7 | createGetCaptionsSummary, 8 | SummarizeCaptions, 9 | } from "./get-captions-summary.ts"; 10 | import { 11 | createGetYoutubeCaptions, 12 | FetchYoutubeCaptions, 13 | FetchYoutubeVideoData, 14 | } from "./get-youtube-captions.ts"; 15 | import { 16 | createGetYoutubeVideoSummary, 17 | GetCaptionsSummary, 18 | GetYoutubeCaptions, 19 | GetYoutubeVideoSummary, 20 | } from "./get-youtube-video-summary.ts"; 21 | import { 22 | ChatGptClientCreationError, 23 | createSummarizeCaptionsWithChatGpt, 24 | GptModel, 25 | } from "./summarize-captions-with-chat-gpt.ts"; 26 | import { createOk, Failure, Ok, Result } from "./result.ts"; 27 | import { createTestGetYoutubeVideoSummaryForVideoId } from "../test/get-youtube-video-summary.test.ts"; 28 | 29 | export interface AppConfig { 30 | getYoutubeVideoSummary: GetYoutubeVideoSummary; 31 | } 32 | 33 | export function bootstrapGetYoutubeVideoSummary( 34 | fetchYoutubeVideoData: FetchYoutubeVideoData, 35 | fetchYoutubeCaptions: FetchYoutubeCaptions, 36 | summarizeCaptions: SummarizeCaptions, 37 | ): GetYoutubeVideoSummary { 38 | const getYoutubeCaptions: GetYoutubeCaptions = createGetYoutubeCaptions( 39 | fetchYoutubeVideoData, 40 | fetchYoutubeCaptions, 41 | ); 42 | const getCaptionsSummary: GetCaptionsSummary = createGetCaptionsSummary( 43 | summarizeCaptions, 44 | ); 45 | return createGetYoutubeVideoSummary(getYoutubeCaptions, getCaptionsSummary); 46 | } 47 | 48 | export class ProdAppConfig implements AppConfig { 49 | readonly getYoutubeVideoSummary: GetYoutubeVideoSummary; 50 | private chatGptClient: OpenAI; 51 | 52 | private constructor(gptModel: GptModel) { 53 | this.chatGptClient = new OpenAI({ 54 | apiKey: Deno.env.get("OPENAI_API_KEY"), 55 | }); 56 | 57 | this.getYoutubeVideoSummary = bootstrapGetYoutubeVideoSummary( 58 | fetchYoutubeVideoData, 59 | fetchYoutubeCaptions, 60 | createSummarizeCaptionsWithChatGpt(this.chatGptClient, gptModel), 61 | ); 62 | } 63 | 64 | static create( 65 | desiredGptModel?: string, 66 | ): Ok | Failure { 67 | const gptModelResult = GptModel.createGptModel( 68 | desiredGptModel || "gpt-4o-mini-2024-07-18", 69 | ); 70 | if (gptModelResult.result === Result.Failure) { 71 | return gptModelResult; 72 | } else { 73 | return createOk(new ProdAppConfig(gptModelResult.data)); 74 | } 75 | } 76 | } 77 | 78 | export class TestAppConfig implements AppConfig { 79 | readonly getYoutubeVideoSummary: GetYoutubeVideoSummary; 80 | 81 | private constructor(getYoutubeVideoSummary: GetYoutubeVideoSummary) { 82 | this.getYoutubeVideoSummary = getYoutubeVideoSummary; 83 | } 84 | 85 | static async createForVideoId(videoId: string): Promise> { 86 | return createOk( 87 | new TestAppConfig( 88 | await createTestGetYoutubeVideoSummaryForVideoId(videoId), 89 | ), 90 | ); 91 | } 92 | } 93 | 94 | export const TEST_CONFIG_VIDEO_ID = "test"; 95 | 96 | export function getAppConfig(desiredGptModel?: string) { 97 | return Deno.env.get("TEST") === "true" 98 | ? TestAppConfig.createForVideoId(TEST_CONFIG_VIDEO_ID) 99 | : ProdAppConfig.create(desiredGptModel); 100 | } 101 | -------------------------------------------------------------------------------- /app/src/failure.ts: -------------------------------------------------------------------------------- 1 | import { Failure } from "./result.ts"; 2 | 3 | export enum FailureType { 4 | CouldNotFindTheVideo = "could not find the video", 5 | CouldNotFindTheCaptions = "could not find the captions", 6 | FailedToFetch = "failed to fetch", 7 | FailedToParseYoutubeData = "failed to parse youtube data", 8 | FailedToSummarizeTheVideo = "failed to summarize the video", 9 | } 10 | 11 | export type InternalFailure = Failure; 12 | -------------------------------------------------------------------------------- /app/src/fetch-youtube-data.ts: -------------------------------------------------------------------------------- 1 | import { FailureType, InternalFailure } from "./failure.ts"; 2 | import { 3 | FetchYoutubeCaptions, 4 | FetchYoutubeVideoData, 5 | } from "./get-youtube-captions.ts"; 6 | import { createFailure, createOk, Ok } from "./result.ts"; 7 | 8 | export const fetchYoutubeVideoData: FetchYoutubeVideoData = async ( 9 | videoId: string, 10 | ): Promise | InternalFailure> => { 11 | const response = await fetch(`https://youtube.com/watch?v=${videoId}`); 12 | if (response.ok) { 13 | const text = await response.text(); 14 | return createOk(text); 15 | } 16 | if (response.status === 404) { 17 | return createFailure(FailureType.CouldNotFindTheVideo); 18 | } 19 | return createFailure(FailureType.FailedToFetch, response.statusText); 20 | }; 21 | 22 | export const fetchYoutubeCaptions: FetchYoutubeCaptions = async ( 23 | captionsUrl: string, 24 | ): Promise | InternalFailure> => { 25 | const response = await fetch(captionsUrl); 26 | if (response.ok) { 27 | const text = await response.text(); 28 | return createOk(text); 29 | } 30 | if (response.status === 404) { 31 | return createFailure(FailureType.CouldNotFindTheCaptions); 32 | } 33 | return createFailure(FailureType.FailedToFetch, response.statusText); 34 | }; 35 | -------------------------------------------------------------------------------- /app/src/get-captions-summary.ts: -------------------------------------------------------------------------------- 1 | import { InternalFailure } from "./failure.ts"; 2 | import { GetCaptionsSummary } from "./get-youtube-video-summary.ts"; 3 | import { Ok } from "./result.ts"; 4 | 5 | export type SummarizeCaptions = ( 6 | systemPrompt: string, 7 | captions: string, 8 | ) => Promise | InternalFailure>; 9 | 10 | export function createGetCaptionsSummary( 11 | summarizeCaptions: SummarizeCaptions, 12 | ): GetCaptionsSummary { 13 | return (captions: string) => { 14 | return getCaptionsSummary(captions, summarizeCaptions); 15 | }; 16 | } 17 | 18 | function getCaptionsSummary( 19 | captions: string, 20 | summarizeCaptions: SummarizeCaptions, 21 | ): Promise | InternalFailure> { 22 | const SYSTEM_PROMPT = `You will be provided with video captions. 23 | Summarize the video underlining the most important themes. 24 | Try to keep it as short as possible without loosing context. 25 | Ignore sponsored segments. 26 | Ignore mentions of Patreon and calls to subscribe, like and comment.`; 27 | 28 | const sanitizedCaptions = stripXmlTags(captions); 29 | 30 | const summary = summarizeCaptions(SYSTEM_PROMPT, sanitizedCaptions); 31 | return summary; 32 | } 33 | 34 | function stripXmlTags(text: string): string { 35 | let resultingText = ""; 36 | 37 | const chars = [...text]; 38 | let start = 0; 39 | 40 | chars.forEach((char, index) => { 41 | if (char === "<") { 42 | resultingText += text.slice(start, index) + " "; 43 | } else if (char === ">") { 44 | start = index + 1; 45 | } 46 | }); 47 | 48 | if (start === 0) { 49 | // Most likely it wasn't an XML, so just return the input then. 50 | return text; 51 | } 52 | 53 | return resultingText.trim().replace(/\s\s+/g, " "); 54 | } 55 | -------------------------------------------------------------------------------- /app/src/get-youtube-captions.ts: -------------------------------------------------------------------------------- 1 | import { FailureType, InternalFailure } from "./failure.ts"; 2 | import { GetYoutubeCaptions } from "./get-youtube-video-summary.ts"; 3 | import { createFailure, createOk, Ok, Result } from "./result.ts"; 4 | 5 | // Just specifying whatever I'm going to use. 6 | interface CaptionsMetadata { 7 | captionTracks: CaptionTrack[]; 8 | defaultAuidoTrackIndex: number; 9 | } 10 | 11 | interface CaptionTrack { 12 | baseUrl: string; 13 | languageCode: string; 14 | } 15 | 16 | export type FetchYoutubeVideoData = ( 17 | videoId: string, 18 | ) => Promise | InternalFailure>; 19 | export type FetchYoutubeCaptions = ( 20 | captionsUrl: string, 21 | ) => Promise | InternalFailure>; 22 | 23 | export function createGetYoutubeCaptions( 24 | fetchYoutubeVideoData: FetchYoutubeVideoData, 25 | fetchYoutubeCaptions: FetchYoutubeCaptions, 26 | ): GetYoutubeCaptions { 27 | return (videoId: string) => { 28 | return getYoutubeCaptions( 29 | videoId, 30 | fetchYoutubeVideoData, 31 | fetchYoutubeCaptions, 32 | ); 33 | }; 34 | } 35 | 36 | async function getYoutubeCaptions( 37 | videoId: string, 38 | fetchYoutubeVideoData: FetchYoutubeVideoData, 39 | fetchYoutubeCaptions: FetchYoutubeCaptions, 40 | ): Promise | InternalFailure> { 41 | const fetchYoutubeVideoDataResult = await fetchYoutubeVideoData(videoId); 42 | if (fetchYoutubeVideoDataResult.result === Result.Failure) { 43 | // Couldn't fetch youtube video data, propagate the failure. 44 | return fetchYoutubeVideoDataResult; 45 | } 46 | const youtubeVideoData = fetchYoutubeVideoDataResult.data; 47 | const dereferencingResult = dereferenceCaptionsMetadata( 48 | youtubeVideoData, 49 | ); 50 | if (dereferencingResult.result === Result.Failure) { 51 | // Couldn't dereference the captions, propagate the failure. 52 | return dereferencingResult; 53 | } 54 | const captionsMetadata = dereferencingResult.data; 55 | const captionsUrl = captionsMetadata.captionTracks[0].baseUrl; 56 | const captions = fetchYoutubeCaptions(captionsUrl); 57 | return captions; 58 | } 59 | 60 | function dereferenceCaptionsMetadata( 61 | youtubeVideoData: string, 62 | ): Ok | InternalFailure { 63 | // The response is a huge HTML that somewhere contains a JSON object that 64 | // is stored under the `playerCaptionsTracklistRenderer` key. 65 | // This function just searches for that key and tries to scan the JSON. 66 | const CAPTIONS_KEY = '"playerCaptionsTracklistRenderer":'; 67 | 68 | const captionsIndex = youtubeVideoData.indexOf(CAPTIONS_KEY); 69 | if (captionsIndex === -1) { 70 | return createFailure( 71 | FailureType.FailedToParseYoutubeData, 72 | "Looks like the video does not have any captions.", 73 | ); 74 | } 75 | 76 | const start = captionsIndex + CAPTIONS_KEY.length; 77 | const closingBracketResult = findClosingBracket(youtubeVideoData, start); 78 | if (closingBracketResult.result === Result.Failure) { 79 | return closingBracketResult; 80 | } 81 | const end = closingBracketResult.data; 82 | const json = JSON.parse(youtubeVideoData.slice(start, end)); 83 | return createOk(json); 84 | } 85 | 86 | function findClosingBracket( 87 | text: string, 88 | start: number, 89 | ): Ok | InternalFailure { 90 | if (text[start] != "{") { 91 | return createFailure(FailureType.FailedToParseYoutubeData); 92 | } 93 | 94 | let index = start + 1; 95 | let depth = 1; 96 | 97 | while (depth != 0 && index < text.length) { 98 | if (text[index] === "{") { 99 | depth += 1; 100 | } 101 | if (text[index] === "}") { 102 | depth -= 1; 103 | } 104 | index += 1; 105 | } 106 | 107 | if (depth != 0) { 108 | return createFailure(FailureType.FailedToParseYoutubeData); 109 | } 110 | 111 | return createOk(index); 112 | } 113 | -------------------------------------------------------------------------------- /app/src/get-youtube-video-summary.ts: -------------------------------------------------------------------------------- 1 | import { InternalFailure } from "./failure.ts"; 2 | import { Ok, Result } from "./result.ts"; 3 | 4 | export type GetYoutubeVideoSummary = ( 5 | videoId: string, 6 | ) => Promise | InternalFailure>; 7 | export type GetYoutubeCaptions = ( 8 | videoId: string, 9 | ) => Promise | InternalFailure>; 10 | export type GetCaptionsSummary = ( 11 | videoId: string, 12 | ) => Promise | InternalFailure>; 13 | 14 | export function createGetYoutubeVideoSummary( 15 | getYoutubeCaptions: GetYoutubeCaptions, 16 | getCaptionsSummary: GetCaptionsSummary, 17 | ): GetYoutubeVideoSummary { 18 | return (videoId: string) => { 19 | return getYoutubeVideoSummary( 20 | videoId, 21 | getYoutubeCaptions, 22 | getCaptionsSummary, 23 | ); 24 | }; 25 | } 26 | 27 | async function getYoutubeVideoSummary( 28 | videoId: string, 29 | getYoutubeCaptions: GetYoutubeCaptions, 30 | getCaptionsSummary: GetCaptionsSummary, 31 | ): Promise | InternalFailure> { 32 | const getYoutubeCaptionsResult = await getYoutubeCaptions(videoId); 33 | if (getYoutubeCaptionsResult.result === Result.Failure) { 34 | console.info({ 35 | errorType: getYoutubeCaptionsResult.failure, 36 | errorMessage: getYoutubeCaptionsResult.message || "", 37 | }); 38 | // Couldn't get youtube captions, propagate the failure. 39 | return getYoutubeCaptionsResult; 40 | } 41 | const captions = getYoutubeCaptionsResult.data; 42 | const summary = await getCaptionsSummary(captions); 43 | if (summary.result === Result.Failure) { 44 | console.info({ 45 | errorType: summary.failure, 46 | errorMessage: summary.message || "", 47 | }); 48 | } 49 | return summary; 50 | } 51 | -------------------------------------------------------------------------------- /app/src/result.ts: -------------------------------------------------------------------------------- 1 | // My own implementation of the Result type. 2 | export enum Result { 3 | Ok = "Ok", 4 | Failure = "Failure", 5 | } 6 | 7 | export type Ok = { 8 | result: Result.Ok; 9 | data: T; 10 | }; 11 | 12 | export const createOk = (data: T): Ok => ({ 13 | result: Result.Ok, 14 | data, 15 | }); 16 | 17 | export type Failure = { 18 | result: Result.Failure; 19 | failure: T; 20 | message?: string; 21 | }; 22 | 23 | export const createFailure = (failure: T, message?: string): Failure => ({ 24 | result: Result.Failure, 25 | failure, 26 | message, 27 | }); 28 | -------------------------------------------------------------------------------- /app/src/summarize-captions-with-chat-gpt.ts: -------------------------------------------------------------------------------- 1 | import { APIError, OpenAI } from "../deps.ts"; 2 | import { SummarizeCaptions } from "./get-captions-summary.ts"; 3 | import { FailureType, InternalFailure } from "./failure.ts"; 4 | import { createFailure, createOk, Failure, Ok } from "./result.ts"; 5 | 6 | export enum ChatGptClientCreationError { 7 | ModelNotSupported = "model not supported", 8 | } 9 | 10 | export class GptModel { 11 | private static readonly supportedModels: Set = new Set([ 12 | "gpt-4-1106-preview", 13 | "gpt-4", 14 | "gpt-3.5-turbo-1106", 15 | "gpt-4o-mini-2024-07-18", 16 | ]); 17 | 18 | private readonly _model: string; 19 | 20 | get model(): string { 21 | return this._model; 22 | } 23 | 24 | private constructor(gptModel: string) { 25 | this._model = gptModel; 26 | } 27 | 28 | static createGptModel( 29 | gptModel: string, 30 | ): Ok | Failure { 31 | if (this.supportedModels.has(gptModel)) { 32 | return createOk(new GptModel(gptModel)); 33 | } 34 | const failureMessage = 35 | `Model "${gptModel}" is not supported. Supported models: ${ 36 | [...this.supportedModels].join(", ") 37 | }.`; 38 | return createFailure( 39 | ChatGptClientCreationError.ModelNotSupported, 40 | failureMessage, 41 | ); 42 | } 43 | } 44 | 45 | export function createSummarizeCaptionsWithChatGpt( 46 | chatGptClient: OpenAI, 47 | gptModel: GptModel, 48 | ): SummarizeCaptions { 49 | return (systemPrompt: string, captions: string) => { 50 | return summarizeCaptionsWithChatGpt( 51 | systemPrompt, 52 | captions, 53 | chatGptClient, 54 | gptModel, 55 | ); 56 | }; 57 | } 58 | 59 | async function summarizeCaptionsWithChatGpt( 60 | systemPrompt: string, 61 | captions: string, 62 | chatGptClient: OpenAI, 63 | gptModel: GptModel, 64 | ): Promise | InternalFailure> { 65 | try { 66 | const result = await chatGptClient.chat.completions.create({ 67 | model: gptModel.model, 68 | messages: [ 69 | { role: "system", content: systemPrompt }, 70 | { role: "user", content: captions }, 71 | ], 72 | n: 1, 73 | }); 74 | 75 | const content = result.choices[0].message.content; 76 | 77 | if (content === null) { 78 | return createFailure( 79 | FailureType.FailedToSummarizeTheVideo, 80 | "The summary was empty.", 81 | ); 82 | } 83 | return createOk(content); 84 | } catch (error) { 85 | if (error instanceof APIError) { 86 | return createFailure( 87 | FailureType.FailedToSummarizeTheVideo, 88 | `Downstream service error ${error.code} ${error.message}` || "", 89 | ); 90 | } 91 | return createFailure(FailureType.FailedToSummarizeTheVideo); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /app/test/common.ts: -------------------------------------------------------------------------------- 1 | export const createPromise = (data: T): Promise => ( 2 | new Promise((resolve) => resolve(data)) 3 | ); 4 | -------------------------------------------------------------------------------- /app/test/data/expectedCaptionsUrl.txt: -------------------------------------------------------------------------------- 1 | https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ&ei=xeuaZb6EGceki9oPpfaKgAs&caps=asr&opi=112496729&xoaf=5&hl=pl&ip=0.0.0.0&ipbits=0&expire=1704676917&sparams=ip,ipbits,expire,v,ei,caps,opi,xoaf&signature=A7BEA9EFEA4FC273F38A25D85737D7D6217123A5.7ECDD435979CB04D6545EC4AC24EFE6204EF28E4&key=yt8&kind=asr&lang=en -------------------------------------------------------------------------------- /app/test/data/successfulYoutubeCaptionsResponse.xml: -------------------------------------------------------------------------------- 1 | [Music]we&#39;re no strangers tolove you know the rules and so doI I full commitments while I&#39;m thinkingofyou wouldn&#39;t get this from any other guyI just want to tell you how I&#39;mfeeling got to make you understand NeverGoing To Give You Up never going to letyou down never going to run around anddesert you never going to make you crynever going to say goodbye never goingto tell a lie and hurt youwe&#39;ve known each other for solong your heart&#39;s been aching but yourto sh to say it inside we both knowwhat&#39;s been goingon we know the game and we&#39;re going toplaying and if you ask me how I&#39;mfeeling don&#39;t tell me you&#39;re too my yousee Never Going To Give You Up nevergoing to let you down never to runaround and desert you never going tomake you cry never going to say goodbyenever going to tell a lie and hurt younever going to give you up never goingto let you down never going to runaround and desert you never going tomake you cry never going to sing goodbyegoing to tell a lie and hurtyougiveyou giveyou going to give going to giveyou going to give going to giveyou we&#39;ve known each other for solong your heart&#39;s been aching but you&#39;retoo sh to say inside we both know what&#39;sbeen goingon we the game and we&#39;re going to playit I just want to tell you how I&#39;mfeeling got to make you understand NeverGoing To Give You Up never going to letyou down never going to run around anddesert you never going to make you crynever going to say goodbye never goingto tell you my and Hurt You Never GoingTo Give You Upnever going to let you down never goingto run around and desert you never goingto make you C never going to say goodbyenever going totell and Hur You Never Going To Give YouUp never going to let you down nevergoing to run around and desert you nevergoing to make you going to[Music]goodbyeand -------------------------------------------------------------------------------- /app/test/data/successfulYoutubeDataResponse.html: -------------------------------------------------------------------------------- 1 | 2 | "captions":{"playerCaptionsTracklistRenderer":{"captionTracks":[{"baseUrl":"https://www.youtube.com/api/timedtext?v=dQw4w9WgXcQ\u0026ei=xeuaZb6EGceki9oPpfaKgAs\u0026caps=asr\u0026opi=112496729\u0026xoaf=5\u0026hl=pl\u0026ip=0.0.0.0\u0026ipbits=0\u0026expire=1704676917\u0026sparams=ip,ipbits,expire,v,ei,caps,opi,xoaf\u0026signature=A7BEA9EFEA4FC273F38A25D85737D7D6217123A5.7ECDD435979CB04D6545EC4AC24EFE6204EF28E4\u0026key=yt8\u0026kind=asr\u0026lang=en","name":{"simpleText":"angielski (wygenerowane automatycznie)"},"vssId":"a.en","languageCode":"en","kind":"asr","isTranslatable":true,"trackName":""}],"audioTracks":[{"captionTrackIndices":[0]}],"translationLanguages":[{"languageCode":"af","languageName":{"simpleText":"Afrikaans"}},{"languageCode":"ay","languageName":{"simpleText":"Ajmara"}},{"languageCode":"ak","languageName":{"simpleText":"Akan"}},{"languageCode":"sq","languageName":{"simpleText":"Albański"}},{"languageCode":"am","languageName":{"simpleText":"Amharski"}},{"languageCode":"en","languageName":{"simpleText":"Angielski"}},{"languageCode":"ar","languageName":{"simpleText":"Arabski"}},{"languageCode":"as","languageName":{"simpleText":"Asamski"}},{"languageCode":"az","languageName":{"simpleText":"Azerbejdżański"}},{"languageCode":"eu","languageName":{"simpleText":"Baskijski"}},{"languageCode":"bn","languageName":{"simpleText":"Bengalski"}},{"languageCode":"bho","languageName":{"simpleText":"Bhodżpuri"}},{"languageCode":"be","languageName":{"simpleText":"Białoruski"}},{"languageCode":"my","languageName":{"simpleText":"Birmański"}},{"languageCode":"bs","languageName":{"simpleText":"Bośniacki"}},{"languageCode":"bg","languageName":{"simpleText":"Bułgarski"}},{"languageCode":"ceb","languageName":{"simpleText":"Cebuański"}},{"languageCode":"zh-Hant","languageName":{"simpleText":"Chiński (Tradycyjne)"}},{"languageCode":"zh-Hans","languageName":{"simpleText":"Chiński (Uproszczone)"}},{"languageCode":"hr","languageName":{"simpleText":"Chorwacki"}},{"languageCode":"cs","languageName":{"simpleText":"Czeski"}},{"languageCode":"da","languageName":{"simpleText":"Duński"}},{"languageCode":"eo","languageName":{"simpleText":"Esperanto"}},{"languageCode":"et","languageName":{"simpleText":"Estoński"}},{"languageCode":"ee","languageName":{"simpleText":"Ewe"}},{"languageCode":"fil","languageName":{"simpleText":"Filipiński"}},{"languageCode":"fi","languageName":{"simpleText":"Fiński"}},{"languageCode":"fr","languageName":{"simpleText":"Francuski"}},{"languageCode":"gl","languageName":{"simpleText":"Galicyjski"}},{"languageCode":"lg","languageName":{"simpleText":"Ganda"}},{"languageCode":"el","languageName":{"simpleText":"Grecki"}},{"languageCode":"ka","languageName":{"simpleText":"Gruziński"}},{"languageCode":"gn","languageName":{"simpleText":"Guarani"}},{"languageCode":"gu","languageName":{"simpleText":"Gudżarati"}},{"languageCode":"ha","languageName":{"simpleText":"Hausa"}},{"languageCode":"haw","languageName":{"simpleText":"Hawajski"}},{"languageCode":"iw","languageName":{"simpleText":"Hebrajski"}},{"languageCode":"hi","languageName":{"simpleText":"Hindi"}},{"languageCode":"es","languageName":{"simpleText":"Hiszpański"}},{"languageCode":"hmn","languageName":{"simpleText":"Hmong"}},{"languageCode":"ig","languageName":{"simpleText":"Igbo"}},{"languageCode":"id","languageName":{"simpleText":"Indonezyjski"}},{"languageCode":"ga","languageName":{"simpleText":"Irlandzki"}},{"languageCode":"is","languageName":{"simpleText":"Islandzki"}},{"languageCode":"ja","languageName":{"simpleText":"Japoński"}},{"languageCode":"jv","languageName":{"simpleText":"Jawajski"}},{"languageCode":"yi","languageName":{"simpleText":"Jidysz"}},{"languageCode":"yo","languageName":{"simpleText":"Joruba"}},{"languageCode":"kn","languageName":{"simpleText":"Kannada"}},{"languageCode":"ca","languageName":{"simpleText":"Kataloński"}},{"languageCode":"kk","languageName":{"simpleText":"Kazachski"}},{"languageCode":"qu","languageName":{"simpleText":"Keczua"}},{"languageCode":"km","languageName":{"simpleText":"Khmerski"}},{"languageCode":"xh","languageName":{"simpleText":"Khosa"}},{"languageCode":"rw","languageName":{"simpleText":"Kinya-Ruanda"}},{"languageCode":"ky","languageName":{"simpleText":"Kirgiski"}},{"languageCode":"ko","languageName":{"simpleText":"Koreański"}},{"languageCode":"co","languageName":{"simpleText":"Korsykański"}},{"languageCode":"ht","languageName":{"simpleText":"Kreolski Haitański"}},{"languageCode":"kri","languageName":{"simpleText":"Krio"}},{"languageCode":"ku","languageName":{"simpleText":"Kurdyjski"}},{"languageCode":"lo","languageName":{"simpleText":"Laotański"}},{"languageCode":"ln","languageName":{"simpleText":"Lingala"}},{"languageCode":"lt","languageName":{"simpleText":"Litewski"}},{"languageCode":"lb","languageName":{"simpleText":"Luksemburski"}},{"languageCode":"la","languageName":{"simpleText":"Łaciński"}},{"languageCode":"lv","languageName":{"simpleText":"Łotewski"}},{"languageCode":"mk","languageName":{"simpleText":"Macedoński"}},{"languageCode":"ml","languageName":{"simpleText":"Malajalam"}},{"languageCode":"ms","languageName":{"simpleText":"Malajski"}},{"languageCode":"dv","languageName":{"simpleText":"Malediwski"}},{"languageCode":"mg","languageName":{"simpleText":"Malgaski"}},{"languageCode":"mt","languageName":{"simpleText":"Maltański"}},{"languageCode":"mi","languageName":{"simpleText":"Maoryjski"}},{"languageCode":"mr","languageName":{"simpleText":"Marathi"}},{"languageCode":"mn","languageName":{"simpleText":"Mongolski"}},{"languageCode":"ne","languageName":{"simpleText":"Nepalski"}},{"languageCode":"nl","languageName":{"simpleText":"Niderlandzki"}},{"languageCode":"de","languageName":{"simpleText":"Niemiecki"}},{"languageCode":"ny","languageName":{"simpleText":"Njandża"}},{"languageCode":"no","languageName":{"simpleText":"Norweski"}},{"languageCode":"or","languageName":{"simpleText":"Orija"}},{"languageCode":"hy","languageName":{"simpleText":"Ormiański"}},{"languageCode":"om","languageName":{"simpleText":"Oromo"}},{"languageCode":"ps","languageName":{"simpleText":"Paszto"}},{"languageCode":"pa","languageName":{"simpleText":"Pendżabski"}},{"languageCode":"fa","languageName":{"simpleText":"Perski"}},{"languageCode":"pl","languageName":{"simpleText":"Polski"}},{"languageCode":"pt","languageName":{"simpleText":"Portugalski"}},{"languageCode":"ru","languageName":{"simpleText":"Rosyjski"}},{"languageCode":"ro","languageName":{"simpleText":"Rumuński"}},{"languageCode":"sm","languageName":{"simpleText":"Samoański"}},{"languageCode":"sa","languageName":{"simpleText":"Sanskryt"}},{"languageCode":"sr","languageName":{"simpleText":"Serbski"}},{"languageCode":"sn","languageName":{"simpleText":"Shona"}},{"languageCode":"sd","languageName":{"simpleText":"Sindhi"}},{"languageCode":"sk","languageName":{"simpleText":"Słowacki"}},{"languageCode":"sl","languageName":{"simpleText":"Słoweński"}},{"languageCode":"so","languageName":{"simpleText":"Somalijski"}},{"languageCode":"st","languageName":{"simpleText":"Sotho Południowy"}},{"languageCode":"nso","languageName":{"simpleText":"Sotho Północny"}},{"languageCode":"sw","languageName":{"simpleText":"Suahili"}},{"languageCode":"su","languageName":{"simpleText":"Sundajski"}},{"languageCode":"si","languageName":{"simpleText":"Syngaleski"}},{"languageCode":"gd","languageName":{"simpleText":"Szkocki Gaelicki"}},{"languageCode":"sv","languageName":{"simpleText":"Szwedzki"}},{"languageCode":"tg","languageName":{"simpleText":"Tadżycki"}},{"languageCode":"th","languageName":{"simpleText":"Tajski"}},{"languageCode":"ta","languageName":{"simpleText":"Tamilski"}},{"languageCode":"tt","languageName":{"simpleText":"Tatarski"}},{"languageCode":"te","languageName":{"simpleText":"Telugu"}},{"languageCode":"ti","languageName":{"simpleText":"Tigrinia"}},{"languageCode":"ts","languageName":{"simpleText":"Tsonga"}},{"languageCode":"tr","languageName":{"simpleText":"Turecki"}},{"languageCode":"tk","languageName":{"simpleText":"Turkmeński"}},{"languageCode":"ug","languageName":{"simpleText":"Ujgurski"}},{"languageCode":"uk","languageName":{"simpleText":"Ukraiński"}},{"languageCode":"ur","languageName":{"simpleText":"Urdu"}},{"languageCode":"uz","languageName":{"simpleText":"Uzbecki"}},{"languageCode":"cy","languageName":{"simpleText":"Walijski"}},{"languageCode":"hu","languageName":{"simpleText":"Węgierski"}},{"languageCode":"vi","languageName":{"simpleText":"Wietnamski"}},{"languageCode":"it","languageName":{"simpleText":"Włoski"}},{"languageCode":"fy","languageName":{"simpleText":"Zachodniofryzyjski"}},{"languageCode":"zu","languageName":{"simpleText":"Zulu"}}],"defaultAudioTrackIndex":0}})(); 3 | -------------------------------------------------------------------------------- /app/test/get-captions-summary.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals } from "https://deno.land/std@0.211.0/assert/assert_equals.ts"; 2 | import { 3 | createGetCaptionsSummary, 4 | SummarizeCaptions, 5 | } from "../src/get-captions-summary.ts"; 6 | import { createOk } from "../src/result.ts"; 7 | import { createPromise } from "./common.ts"; 8 | 9 | const returnPassedInCaptions: SummarizeCaptions = ( 10 | _systemPrompt: string, 11 | captions: string, 12 | ) => createPromise(createOk(captions)); 13 | 14 | Deno.test("should call summarize captions function without modifying the input", async () => { 15 | // given 16 | const getCaptionsSummary = createGetCaptionsSummary( 17 | returnPassedInCaptions, 18 | ); 19 | const captions = "Some example captions"; 20 | 21 | // when 22 | const result = await getCaptionsSummary(captions); 23 | 24 | // then 25 | assertEquals(result, createOk(captions)); 26 | }); 27 | 28 | Deno.test("should strip XML tags", async () => { 29 | // given 30 | const getCaptionsSummary = createGetCaptionsSummary( 31 | returnPassedInCaptions, 32 | ); 33 | const captions = 34 | `[Music]we're no strangers tolove you know the rules and so do`; 35 | 36 | // when 37 | const result = await getCaptionsSummary(captions); 38 | 39 | // then 40 | const expectedCaptions = 41 | "[Music] we're no strangers to love you know the rules and so do"; 42 | assertEquals(result, createOk(expectedCaptions)); 43 | }); 44 | -------------------------------------------------------------------------------- /app/test/get-youtube-video-summary.test.ts: -------------------------------------------------------------------------------- 1 | import { bootstrapGetYoutubeVideoSummary } from "../src/app-config.ts"; 2 | import { FailureType } from "../src/failure.ts"; 3 | import { SummarizeCaptions } from "../src/get-captions-summary.ts"; 4 | import { 5 | FetchYoutubeCaptions, 6 | FetchYoutubeVideoData, 7 | } from "../src/get-youtube-captions.ts"; 8 | import { createFailure, createOk } from "../src/result.ts"; 9 | import { assertEquals } from "https://deno.land/std@0.211.0/assert/mod.ts"; 10 | import { createPromise } from "./common.ts"; 11 | 12 | export const SUCCESSFUL_CHAT_GPT_CAPTIONS_SUMMARY = 13 | `The video is a music video for the song "Never Gonna Give You Up" by Rick 14 | Astley. The captions display the lyrics of the song, which talk about love 15 | and commitment, assuring that the singer will never give up on the person 16 | they love. The video mainly consists of the lyrics being displayed on the 17 | screen, with the song playing in the background.`; 18 | 19 | // End-to-end test with real data from YouTube. 20 | Deno.test("should get youtube video summary", async () => { 21 | // given 22 | const videoId = "videoId"; 23 | const getYoutubeVideoSummary = 24 | await createTestGetYoutubeVideoSummaryForVideoId(videoId); 25 | 26 | // when 27 | const result = await getYoutubeVideoSummary(videoId); 28 | 29 | // then 30 | assertEquals(result, createOk(SUCCESSFUL_CHAT_GPT_CAPTIONS_SUMMARY)); 31 | }); 32 | 33 | export const createTestGetYoutubeVideoSummaryForVideoId = async ( 34 | videoId: string, 35 | ) => { 36 | const textDecoder = new TextDecoder("utf-8"); 37 | 38 | const SUCCESSFUL_YOUTUBE_DATA_RESPONSE_PATH = 39 | "./app/test/data/successfulYoutubeDataResponse.html"; 40 | const successfulYoutubeDataResponse = textDecoder.decode( 41 | await Deno.readFile(SUCCESSFUL_YOUTUBE_DATA_RESPONSE_PATH), 42 | ); 43 | const fetchYoutubeVideoData = createFetchYoutubeVideoDataForVideoId( 44 | videoId, 45 | successfulYoutubeDataResponse, 46 | ); 47 | 48 | const EXPECTED_CAPTIONS_URL_PATH = "./app/test/data/expectedCaptionsUrl.txt"; 49 | const expectedCaptionsUrl = textDecoder.decode( 50 | await Deno.readFile(EXPECTED_CAPTIONS_URL_PATH), 51 | ); 52 | const SUCCESSFUL_YOUTUBE_CAPTIONS_RESPONSE_PATH = 53 | "./app/test/data/successfulYoutubeCaptionsResponse.xml"; 54 | const successfulYoutubeCaptionsResponse = textDecoder.decode( 55 | await Deno.readFile(SUCCESSFUL_YOUTUBE_CAPTIONS_RESPONSE_PATH), 56 | ); 57 | const getYoutubeCaptions = createGetYoutubeCaptionsForCaptionsUrl( 58 | expectedCaptionsUrl, 59 | successfulYoutubeCaptionsResponse, 60 | ); 61 | 62 | const alwaysSuccessfulSummarizeCaptions = 63 | createAlwaysSuccessfulSummarizeCaptions( 64 | SUCCESSFUL_CHAT_GPT_CAPTIONS_SUMMARY, 65 | ); 66 | 67 | return bootstrapGetYoutubeVideoSummary( 68 | fetchYoutubeVideoData, 69 | getYoutubeCaptions, 70 | alwaysSuccessfulSummarizeCaptions, 71 | ); 72 | }; 73 | 74 | const createFetchYoutubeVideoDataForVideoId = ( 75 | expectedVideoId: string, 76 | dataToReturn: string, 77 | ): FetchYoutubeVideoData => { 78 | return (videoId: string) => { 79 | if (expectedVideoId === videoId) { 80 | return createPromise(createOk(dataToReturn)); 81 | } else { 82 | return createPromise(createFailure(FailureType.CouldNotFindTheVideo)); 83 | } 84 | }; 85 | }; 86 | 87 | const createAlwaysSuccessfulSummarizeCaptions = ( 88 | dataToReturn: string, 89 | ): SummarizeCaptions => { 90 | return (_systemPrompt: string, _captions: string) => 91 | createPromise(createOk(dataToReturn)); 92 | }; 93 | 94 | const createGetYoutubeCaptionsForCaptionsUrl = ( 95 | expectedCaptionsUrl: string, 96 | dataToReturn: string, 97 | ): FetchYoutubeCaptions => { 98 | return (videoId: string) => { 99 | if (videoId === expectedCaptionsUrl) { 100 | return createPromise(createOk(dataToReturn)); 101 | } else { 102 | return createPromise(createFailure(FailureType.CouldNotFindTheCaptions)); 103 | } 104 | }; 105 | }; 106 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { JSX } from "preact"; 2 | 3 | export function Button(props: JSX.HTMLAttributes) { 4 | return ( 5 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /routes/summary/youtube.tsx: -------------------------------------------------------------------------------- 1 | import { FreshContext, Handlers, PageProps } from "$fresh/server.ts"; 2 | import { getAppConfig } from "../../app/src/app-config.ts"; 3 | import { InternalFailure } from "../../app/src/failure.ts"; 4 | import { Result } from "../../app/src/result.ts"; 5 | import { Ok } from "../../app/src/result.ts"; 6 | import { ErrorPage } from "../../components/ErrorPage.tsx"; 7 | import { Summary } from "../../components/Summary.tsx"; 8 | 9 | export const handler: Handlers = { 10 | async GET( 11 | req: Request, 12 | ctx: FreshContext, 13 | ) { 14 | const idParamValue = new URL(req.url).searchParams.get("id") || ""; 15 | const videoId = dereferenceVideoIdFromUrlPath(idParamValue); 16 | const appResult = await getAppConfig(); 17 | if (appResult.result === Result.Failure) { 18 | return ctx.render(appResult); 19 | } 20 | const app = appResult.data; 21 | const summaryResult = await app.getYoutubeVideoSummary(videoId); 22 | return ctx.render(summaryResult); 23 | }, 24 | }; 25 | 26 | export default function YoutubeVideoSummary( 27 | props: PageProps | InternalFailure>, 28 | ) { 29 | if (props.data.result === Result.Failure) { 30 | return ErrorPage( 31 | "Something went wrong", 32 | props.data.message || "", 33 | props.data.failure, 34 | ); 35 | } 36 | return Summary(props.data.data); 37 | } 38 | 39 | function dereferenceVideoIdFromUrlPath(idParamValue: string) { 40 | // I want this to work even if someone pastes an incorrect URL, so I'm not 41 | // using the URL classes and I'm trying to parse the youtube video id by 42 | // hand. 43 | let idStartIndex = -1; 44 | 45 | if (idParamValue.includes("youtube.com")) { 46 | const vKeyId = idParamValue.indexOf("v="); 47 | if (vKeyId != -1) { 48 | idStartIndex = vKeyId + "v=".length; 49 | } else { 50 | // Just give up, I can't dereference this, so let's try with user's 51 | // input. 52 | return idParamValue; 53 | } 54 | } else if (idParamValue.includes("youtu.be/")) { 55 | idStartIndex = idParamValue.indexOf("youtu.be/") + "youtu.be/".length; 56 | } 57 | 58 | if (idStartIndex != -1) { 59 | let idEndIndex = idStartIndex + 1; 60 | while ( 61 | idEndIndex < idParamValue.length && 62 | !isWhitespace(idParamValue[idEndIndex]) && 63 | idParamValue[idEndIndex] != "&" && 64 | idParamValue[idEndIndex] != "?" 65 | ) { 66 | idEndIndex++; 67 | } 68 | return idParamValue.slice(idStartIndex, idEndIndex); 69 | } 70 | 71 | return idParamValue; 72 | } 73 | 74 | function isWhitespace(char: string) { 75 | return /\s/.test(char); 76 | } 77 | -------------------------------------------------------------------------------- /run.ts: -------------------------------------------------------------------------------- 1 | // Entrypoint to run "just tell me" from the command line interface. 2 | import { getAppConfig } from "./app/src/app-config.ts"; 3 | import { Result } from "./app/src/result.ts"; 4 | import { parseArgs } from "https://deno.land/std@0.207.0/cli/parse_args.ts"; 5 | 6 | async function run() { 7 | const argument = Deno.args[0]; 8 | 9 | if (argument === undefined) { 10 | console.log("This script expects a YouTube video id"); 11 | return; 12 | } 13 | const videoId: string = argument; 14 | 15 | const flags = parseArgs(Deno.args, { 16 | string: ["model"], 17 | }); 18 | 19 | const appConfigResult = await getAppConfig(flags.model); 20 | if (appConfigResult.result === Result.Failure) { 21 | console.log(appConfigResult.failure, "\n", appConfigResult.message); 22 | } else { 23 | const appConfig = appConfigResult.data; 24 | const summaryResult = await appConfig.getYoutubeVideoSummary(videoId); 25 | if (summaryResult.result === Result.Ok) { 26 | console.log(summaryResult.data); 27 | } 28 | } 29 | } 30 | 31 | run(); 32 | -------------------------------------------------------------------------------- /static/styles.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { type Config } from "tailwindcss"; 2 | 3 | export default { 4 | content: [ 5 | "{routes,islands,components}/**/*.{ts,tsx}", 6 | ], 7 | } satisfies Config; 8 | -------------------------------------------------------------------------------- /test/main-test.test.ts: -------------------------------------------------------------------------------- 1 | import { createHandler, ServeHandlerInfo } from "$fresh/server.ts"; 2 | import manifest from "../fresh.gen.ts"; 3 | import config from "../fresh.config.ts"; 4 | import { assert } from "$std/assert/assert.ts"; 5 | 6 | const CONN_INFO: ServeHandlerInfo = { 7 | remoteAddr: { hostname: "127.0.0.1", port: 53496, transport: "tcp" }, 8 | }; 9 | 10 | Deno.test("HTTP tests", async (t) => { 11 | Deno.env.set("TEST", "true"); 12 | 13 | const handler = await createHandler(manifest, config); 14 | 15 | await t.step("GET /summary/youtube", async () => { 16 | for ( 17 | const id of [ 18 | "test", 19 | "www.youtube.com/watch?v=test", 20 | "youtu.be/test", 21 | "youtube.com/watch?v=test&trackingId=123", 22 | "youtube.com/watch?v=test I don't know why this was in my clipboard", 23 | "youtu.be/test?trackingId=123", 24 | ] 25 | ) { 26 | const response = await handler( 27 | new Request(`http://127.0.0.1/summary/youtube?id=${id}`), 28 | CONN_INFO, 29 | ); 30 | const text = await response.text(); 31 | assert( 32 | text.includes( 33 | "The video is a music video for the song "Never Gonna Give You Up"", 34 | ), 35 | `Text does not include the expected output for id ${id}. Did get: ${text}`, 36 | ); 37 | } 38 | }); 39 | 40 | Deno.env.delete("TEST"); 41 | }); 42 | --------------------------------------------------------------------------------