diff --git a/enjoy/src/main/providers/index.ts b/enjoy/src/main/providers/index.ts index c2ffd090..fdb41dba 100644 --- a/enjoy/src/main/providers/index.ts +++ b/enjoy/src/main/providers/index.ts @@ -1,2 +1,3 @@ export * from "./audible-provider"; export * from "./ted-provider"; +export * from "./youtube-provider"; diff --git a/enjoy/src/main/providers/youtube-provider.ts b/enjoy/src/main/providers/youtube-provider.ts new file mode 100755 index 00000000..5c2a3fa5 --- /dev/null +++ b/enjoy/src/main/providers/youtube-provider.ts @@ -0,0 +1,82 @@ +import log from "@main/logger"; +import $ from "cheerio"; +import { BrowserView, ipcMain } from "electron"; + +const logger = log.scope("providers/youtube-provider"); + +export class YoutubeProvider { + scrape = async (url: string) => { + return new Promise((resolve, reject) => { + const view = new BrowserView(); + view.webContents.loadURL(url); + logger.debug("started scraping", url); + + view.webContents.on("did-finish-load", () => { + logger.debug("finished scraping", url); + view.webContents + .executeJavaScript(`document.documentElement.innerHTML`) + .then((html) => resolve(html as string)) + .finally(() => { + (view.webContents as any).destroy(); + }); + }); + view.webContents.on( + "did-fail-load", + (_event, _errorCode, error, validatedURL) => { + logger.error("failed scraping", url, error, validatedURL); + (view.webContents as any).destroy(); + reject(); + } + ); + }); + }; + + extractVideos = async (html: string) => { + try { + const json = $.load(html)("script") + .text() + .match(/ytInitialData = ({.*?});/)[1]; + const data = JSON.parse(json); + + const videoContents = + data.contents.twoColumnBrowseResultsRenderer.tabs[1].tabRenderer.content + .richGridRenderer.contents; + + const videoList = videoContents + .filter((i: any) => i.richItemRenderer) + .map((video: any) => { + return { + title: + video.richItemRenderer.content.videoRenderer.title.runs[0].text, + thumbnail: + video.richItemRenderer.content.videoRenderer.thumbnail + .thumbnails[0].url, + videoId: video.richItemRenderer.content.videoRenderer.videoId, + duration: + video.richItemRenderer.content.videoRenderer.lengthText + .simpleText, + }; + }); + + return videoList; + } catch (e) { + logger.error(e); + return []; + } + }; + + videos = async () => { + const html = await this.scrape("https://www.youtube.com/@CNN/videos"); + return this.extractVideos(html); + }; + + registerIpcHandlers = () => { + ipcMain.handle("youtube-provider-videos", async () => { + try { + return await this.videos(); + } catch (error) { + logger.error(error); + } + }); + }; +} diff --git a/enjoy/src/main/window.ts b/enjoy/src/main/window.ts index 9ffffbb8..f7c7e6b1 100644 --- a/enjoy/src/main/window.ts +++ b/enjoy/src/main/window.ts @@ -17,7 +17,7 @@ import fs from "fs-extra"; import "@main/i18n"; import log from "@main/logger"; import { WEB_API_URL, REPO_URL } from "@/constants"; -import { AudibleProvider, TedProvider } from "@main/providers"; +import { AudibleProvider, TedProvider, YoutubeProvider } from "@main/providers"; import Ffmpeg from "@main/ffmpeg"; import { Waveform } from "./waveform"; import url from "url"; @@ -31,6 +31,7 @@ const logger = log.scope("window"); const audibleProvider = new AudibleProvider(); const tedProvider = new TedProvider(); +const youtubeProvider = new YoutubeProvider(); const ffmpeg = new Ffmpeg(); const waveform = new Waveform(); @@ -74,6 +75,9 @@ main.init = () => { // TedProvider tedProvider.registerIpcHandlers(); + // YoutubeProvider + youtubeProvider.registerIpcHandlers(); + // proxy ipcMain.handle("system-proxy-get", (_event) => { let proxy = settings.getSync("proxy"); diff --git a/enjoy/src/preload.ts b/enjoy/src/preload.ts index 962a2f49..dd00f261 100644 --- a/enjoy/src/preload.ts +++ b/enjoy/src/preload.ts @@ -84,6 +84,11 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { return ipcRenderer.invoke("ted-provider-download-talk", url); }, }, + youtube: { + videos: () => { + return ipcRenderer.invoke("youtube-provider-videos"); + }, + }, }, view: { load: ( @@ -192,7 +197,7 @@ contextBridge.exposeInMainWorld("__ENJOY_APP__", { camdict: { lookup: (word: string) => { return ipcRenderer.invoke("camdict-lookup", word); - } + }, }, audios: { findAll: (params: { diff --git a/enjoy/src/renderer/components/videos/index.ts b/enjoy/src/renderer/components/videos/index.ts index b0725b34..2a5c8949 100644 --- a/enjoy/src/renderer/components/videos/index.ts +++ b/enjoy/src/renderer/components/videos/index.ts @@ -6,4 +6,5 @@ export * from "./videos-component"; export * from "./videos-segment"; export * from "./ted-talks-segment"; +export * from "./youtube-videos-segment"; export * from "./video-card"; diff --git a/enjoy/src/renderer/components/videos/youtube-videos-segment.tsx b/enjoy/src/renderer/components/videos/youtube-videos-segment.tsx new file mode 100755 index 00000000..e39bef0f --- /dev/null +++ b/enjoy/src/renderer/components/videos/youtube-videos-segment.tsx @@ -0,0 +1,177 @@ +import { t } from "i18next"; +import { useState, useEffect, useContext } from "react"; +import { AppSettingsProviderContext } from "@renderer/context"; +import { + Button, + ScrollArea, + ScrollBar, + Dialog, + DialogHeader, + DialogTitle, + DialogContent, + DialogFooter, +} from "@renderer/components/ui"; +import { useNavigate } from "react-router-dom"; +import { LoaderIcon } from "lucide-react"; + +export const YoutubeVideosSegment = () => { + const navigate = useNavigate(); + const { EnjoyApp } = useContext(AppSettingsProviderContext); + const [videos, setvideos] = useState([]); + const [selectedVideo, setSelectedVideo] = useState( + null + ); + const [submitting, setSubmitting] = useState(false); + + const addToLibrary = () => { + let url = `https://www.youtube.com/watch?v=${selectedVideo?.videoId}`; + setSubmitting(true); + EnjoyApp.videos + .create(url, { + name: selectedVideo?.title, + }) + .then((record) => { + if (!record) return; + + navigate(`/videos/${record.id}`); + }) + .finally(() => { + setSubmitting(false); + }); + }; + + const fetchYoutubeVideos = async () => { + const cachedVideos = await EnjoyApp.cacheObjects.get("youtube-videos"); + if (cachedVideos) { + setvideos(cachedVideos); + return; + } + + EnjoyApp.providers.youtube + .videos() + .then((videos) => { + if (!videos) return; + + EnjoyApp.cacheObjects.set("youtube-videos", videos, 60 * 10); + setvideos(videos); + }) + .catch((err) => { + console.error(err); + }); + }; + + useEffect(() => { + fetchYoutubeVideos(); + }, []); + + if (!videos?.length) return null; + + return ( + <> +
+
+

+ {t("from")} Youtube CNN +

+
+
+
+ +
+ {videos.map((video) => { + return ( + setSelectedVideo(video)} + /> + ); + })} +
+ +
+ + { + if (!value) setSelectedVideo(null); + }} + > + + + {t("downloadVideo")} + + +
+
+ {selectedVideo?.title} +
+
+
+ {selectedVideo?.title} +
+
+ {selectedVideo?.duration} +
+
+
+ + + + + + + + +
+
+ + ); +}; + +const YoutubeVideoCard = (props: { + video: YoutubeVideoType; + onClick?: () => void; +}) => { + const { video, onClick } = props; + + return ( +
+
+ {video.title} + +
+
{video.duration}
+
+
+
+ {video.title} +
+
+ ); +}; diff --git a/enjoy/src/renderer/pages/home.tsx b/enjoy/src/renderer/pages/home.tsx index 3eaaaf03..1fd0d0ae 100644 --- a/enjoy/src/renderer/pages/home.tsx +++ b/enjoy/src/renderer/pages/home.tsx @@ -5,6 +5,7 @@ import { TedIdeasSegment, VideosSegment, TedTalksSegment, + YoutubeVideosSegment, } from "@renderer/components"; export default () => { @@ -17,6 +18,7 @@ export default () => { + ); diff --git a/enjoy/src/types/enjoy-app.d.ts b/enjoy/src/types/enjoy-app.d.ts index 027900e5..91d27676 100644 --- a/enjoy/src/types/enjoy-app.d.ts +++ b/enjoy/src/types/enjoy-app.d.ts @@ -41,6 +41,9 @@ type EnjoyAppType = { ideas: () => Promise; downloadTalk: (url: string) => Promise<{ audio: string; video: string }>; }; + youtube: { + videos: () => Promise; + }; }; view: { load: ( diff --git a/enjoy/src/types/index.d.ts b/enjoy/src/types/index.d.ts index 2f007d08..b6d7a767 100644 --- a/enjoy/src/types/index.d.ts +++ b/enjoy/src/types/index.d.ts @@ -153,3 +153,10 @@ type ProxyConfigType = { enabled: boolean; url: string; }; + +type YoutubeVideoType = { + title: string; + thumbnail: string; + videoId: string; + duration: string; +};