Feat: add youtube provider (#475)

This commit is contained in:
Harry
2024-04-01 22:51:43 -07:00
committed by GitHub
parent 861f850e1f
commit f4d1d2a730
9 changed files with 284 additions and 2 deletions

View File

@@ -1,2 +1,3 @@
export * from "./audible-provider";
export * from "./ted-provider";
export * from "./youtube-provider";

View File

@@ -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<string>((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);
}
});
};
}

View File

@@ -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");

View File

@@ -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: {

View File

@@ -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";

View File

@@ -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<YoutubeVideoType[]>([]);
const [selectedVideo, setSelectedVideo] = useState<YoutubeVideoType | null>(
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 (
<>
<div className="flex items-start justify-between mb-4">
<div className="space-y-1">
<h2 className="text-2xl font-semibold tracking-tight capitalize">
{t("from")} Youtube CNN
</h2>
</div>
<div className="ml-auto mr-4"></div>
</div>
<ScrollArea>
<div className="flex items-center space-x-4 pb-4">
{videos.map((video) => {
return (
<YoutubeVideoCard
key={video.videoId}
video={video}
onClick={() => setSelectedVideo(video)}
/>
);
})}
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
<Dialog
open={Boolean(selectedVideo)}
onOpenChange={(value) => {
if (!value) setSelectedVideo(null);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("downloadVideo")}</DialogTitle>
</DialogHeader>
<div className="flex items-center mb-4 bg-muted rounded-lg">
<div className="aspect-square h-28 overflow-hidden rounded-l-lg">
<img
src={selectedVideo?.thumbnail}
alt={selectedVideo?.title}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 py-3 px-4 h-28">
<div className="text-lg font-semibold ">
{selectedVideo?.title}
</div>
<div className="text-xs line-clamp-1 mb-2 text-right">
{selectedVideo?.duration}
</div>
</div>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() =>
EnjoyApp.shell.openExternal(
`https://www.youtube.com/watch?v=${selectedVideo?.videoId}`
)
}
className="mr-auto"
>
{t("open")}
</Button>
<Button onClick={() => setSelectedVideo(null)} variant="secondary">
{t("cancel")}
</Button>
<Button onClick={() => addToLibrary()} disabled={submitting}>
{submitting && (
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
)}
{t("downloadVideo")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};
const YoutubeVideoCard = (props: {
video: YoutubeVideoType;
onClick?: () => void;
}) => {
const { video, onClick } = props;
return (
<div onClick={onClick} className="w-64 cursor-pointer">
<div className="aspect-[4/2.5] border rounded-lg overflow-hidden relative">
<img
src={video.thumbnail}
alt={video.title}
className="hover:scale-105 object-cover w-screen"
/>
<div className="absolute bottom-0 left-0 right-0 p-2 bg-black bg-opacity-50">
<div className="text-xs text-white text-right">{video.duration}</div>
</div>
</div>
<div className="text-sm font-semibold mt-4 max-w-full h-5 mb-10">
{video.title}
</div>
</div>
);
};

View File

@@ -5,6 +5,7 @@ import {
TedIdeasSegment,
VideosSegment,
TedTalksSegment,
YoutubeVideosSegment,
} from "@renderer/components";
export default () => {
@@ -17,6 +18,7 @@ export default () => {
<AudibleBooksSegment />
<TedTalksSegment />
<TedIdeasSegment />
<YoutubeVideosSegment />
</div>
</div>
);

View File

@@ -41,6 +41,9 @@ type EnjoyAppType = {
ideas: () => Promise<TedIdeaType[]>;
downloadTalk: (url: string) => Promise<{ audio: string; video: string }>;
};
youtube: {
videos: () => Promise<YoutubeVideoType[]>;
};
};
view: {
load: (

View File

@@ -153,3 +153,10 @@ type ProxyConfigType = {
enabled: boolean;
url: string;
};
type YoutubeVideoType = {
title: string;
thumbnail: string;
videoId: string;
duration: string;
};