Feat: add youtube provider (#475)
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export * from "./audible-provider";
|
||||
export * from "./ted-provider";
|
||||
export * from "./youtube-provider";
|
||||
|
||||
82
enjoy/src/main/providers/youtube-provider.ts
Executable file
82
enjoy/src/main/providers/youtube-provider.ts
Executable 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);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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";
|
||||
|
||||
177
enjoy/src/renderer/components/videos/youtube-videos-segment.tsx
Executable file
177
enjoy/src/renderer/components/videos/youtube-videos-segment.tsx
Executable 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
3
enjoy/src/types/enjoy-app.d.ts
vendored
3
enjoy/src/types/enjoy-app.d.ts
vendored
@@ -41,6 +41,9 @@ type EnjoyAppType = {
|
||||
ideas: () => Promise<TedIdeaType[]>;
|
||||
downloadTalk: (url: string) => Promise<{ audio: string; video: string }>;
|
||||
};
|
||||
youtube: {
|
||||
videos: () => Promise<YoutubeVideoType[]>;
|
||||
};
|
||||
};
|
||||
view: {
|
||||
load: (
|
||||
|
||||
7
enjoy/src/types/index.d.ts
vendored
7
enjoy/src/types/index.d.ts
vendored
@@ -153,3 +153,10 @@ type ProxyConfigType = {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type YoutubeVideoType = {
|
||||
title: string;
|
||||
thumbnail: string;
|
||||
videoId: string;
|
||||
duration: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user