basic client that can fetch trending page

nextui
Guus van Meerveld 8 months ago
parent 098ee2d8ba
commit b6c45a9049

@ -0,0 +1,11 @@
meta {
name: Trending
type: http
seq: 1
}
get {
url: https://invidious.drgns.space/api/v1/trending
body: none
auth: none
}

@ -0,0 +1,15 @@
meta {
name: Trending
type: http
seq: 1
}
get {
url: https://pipedapi.kavin.rocks/trending?region=NL
body: none
auth: none
}
query {
region: NL
}

@ -0,0 +1,5 @@
{
"version": "1",
"name": "MaterialTube",
"type": "collection"
}

@ -11,10 +11,12 @@
"dependencies": { "dependencies": {
"@nextui-org/react": "^2.2.10", "@nextui-org/react": "^2.2.10",
"framer-motion": "^11.0.12", "framer-motion": "^11.0.12",
"ky": "^1.2.2",
"next": "14.1.3", "next": "14.1.3",
"next-pwa": "^5.6.0", "next-pwa": "^5.6.0",
"react": "^18", "react": "^18",
"react-dom": "^18" "react-dom": "^18",
"zod": "^3.22.4"
}, },
"devDependencies": { "devDependencies": {
"@types/next-pwa": "^5.6.9", "@types/next-pwa": "^5.6.9",

@ -0,0 +1,19 @@
"use client";
import Client from "@/client";
import { ApiType } from "@/client/adapters";
import { Component } from "@/typings/component";
import { useEffect } from "react";
export const Video: Component = ({}) => {
useEffect(() => {
const client = new Client([
{ baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious },
{ baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped }
]);
client.getTrending("US").then(console.log);
}, []);
return <></>;
};

@ -1,9 +1,10 @@
import { Button } from "@nextui-org/button"; import { Button } from "@nextui-org/button";
import { Video } from "./Video";
export default function Home() { export default function Home() {
return ( return (
<> <>
<Button>Click me</Button> <Video />
</> </>
); );
} }

@ -1,17 +1,16 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import "./globals.css"; import "./globals.css";
import { Providers } from "./providers"; import { Providers } from "./providers";
import { Component } from "@/typings/component";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: "MaterialTube client",
description: "Generated by create next app" description:
"MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and NextUI.",
applicationName: "MaterialTube"
}; };
export default function RootLayout({ const RootLayout: Component = ({ children }) => {
children
}: Readonly<{
children: React.ReactNode;
}>) {
return ( return (
<html lang="en" className="dark"> <html lang="en" className="dark">
<body> <body>
@ -19,4 +18,6 @@ export default function RootLayout({
</body> </body>
</html> </html>
); );
} };
export default RootLayout;

@ -0,0 +1,16 @@
import { TrendingVideo } from "../typings/trending";
export interface ConnectedAdapter {
getTrending(region: string): Promise<TrendingVideo[]>;
}
export default interface Adapter {
apiType: ApiType;
connect(url: string): ConnectedAdapter;
}
export enum ApiType {
Piped,
Invidious
}

@ -0,0 +1,47 @@
import ky from "ky";
import Trending, { TrendingModel } from "./typings/trending";
import Adapter, { ApiType } from "@/client/adapters";
import Transformer from "./transformer";
const apiPath = (path: string): string => `/api/v1/${path}`;
export type TrendingVideoType = "music" | "gaming" | "news" | "movies";
export const getTrending = async (
baseUrl: string,
region?: string,
type?: TrendingVideoType
): Promise<Trending[]> => {
const url = new URL(apiPath("trending"), baseUrl);
const searchParams = new URLSearchParams();
if (region !== undefined) searchParams.append("region", region);
if (type !== undefined) searchParams.append("type", type);
const response = await ky.get(url, {
searchParams
});
const json = await response.json();
const data = TrendingModel.array().parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Invidious,
connect(url) {
return {
getTrending(region) {
return getTrending(url, region).then(Transformer.trending);
}
};
}
};
export default adapter;

@ -0,0 +1,23 @@
import { TrendingVideo } from "@/client/typings/trending";
import { Thumbnail } from "@/client/typings/thumbnail";
import InvidiousTrending from "./typings/trending";
import InvidiousThumbnail from "./typings/thumbnail";
export default class Transformer {
public static thumbnails(data: InvidiousThumbnail[]): Thumbnail[] {
return data.map((thumbnail) => ({ url: thumbnail.url }));
}
public static trending(data: InvidiousTrending[]): TrendingVideo[] {
return data.map((video) => ({
author: { id: video.authorId, name: video.author },
duration: video.lengthSeconds * 1000,
id: video.videoId,
title: video.title,
thumbnails: Transformer.thumbnails(video.videoThumbnails),
uploaded: new Date(video.published * 1000 ?? 0),
views: video.viewCount
}));
}
}

@ -0,0 +1,12 @@
import z from "zod";
export const ThumbnailModel = z.object({
quality: z.string(),
url: z.string().url(),
width: z.number(),
height: z.number()
});
type Thumbnail = z.infer<typeof ThumbnailModel>;
export default Thumbnail;

@ -0,0 +1,28 @@
import z from "zod";
import { ThumbnailModel } from "./thumbnail";
export const TrendingModel = z.object({
title: z.string(),
videoId: z.string(),
videoThumbnails: ThumbnailModel.array(),
lengthSeconds: z.number(),
viewCount: z.number(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
published: z.number(),
publishedText: z.string(),
description: z.string(),
descriptionHtml: z.string(),
liveNow: z.boolean(),
paid: z.boolean().optional().default(false),
premium: z.boolean()
});
type Trending = z.infer<typeof TrendingModel>;
export default Trending;

@ -0,0 +1,38 @@
import ky from "ky";
import Adapter, { ApiType } from "@/client/adapters";
import Trending, { TrendingModel } from "./typings/trending";
import Transformer from "./transformer";
export const getTrending = async (
apiBaseUrl: string,
region = "US"
): Promise<Trending[]> => {
const url = new URL("/trending", apiBaseUrl);
const response = await ky.get(url, {
searchParams: { region: region.toUpperCase() }
});
const json = await response.json();
const data = TrendingModel.array().parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Piped,
connect(url) {
return {
getTrending(region) {
return getTrending(url, region).then(Transformer.trending);
}
};
}
};
export default adapter;

@ -0,0 +1,35 @@
import { TrendingVideo } from "@/client/typings/trending";
import PipedTrending from "./typings/trending";
const videoIdRegex = /\/watch\?v=(.+)/;
const channelIdRegex = /\/channel\/(.+)/;
export default class Transformer {
public static trending(data: PipedTrending[]): TrendingVideo[] {
return data.map((video) => {
const videoIdMatch = video.url.match(videoIdRegex);
const videoId = videoIdMatch !== null ? videoIdMatch[1] : null;
if (videoId === null) throw new Error("Piped: Missing trending video id");
const channelIdMatch = video.uploaderUrl.match(channelIdRegex);
const channelId = channelIdMatch !== null ? channelIdMatch[1] : null;
if (channelId === null)
throw new Error("Piped: Missing trending channelId");
return {
duration: video.duration * 1000,
views: video.views,
id: videoId,
uploaded: new Date(video.uploaded),
thumbnails: [{ url: video.thumbnail }],
title: video.title,
author: { id: channelId, name: video.uploaderName }
};
});
}
}

@ -0,0 +1,19 @@
import z from "zod";
export const TrendingModel = z.object({
duration: z.number(), // The duration of the trending video in seconds
thumbnail: z.string().url(), // The thumbnail of the trending video
title: z.string(), // The title of the trending video
uploaded: z.number(),
uploadedDate: z.string(), // The date the trending video was uploaded
uploaderName: z.string(),
uploaderAvatar: z.string().url(), // The avatar of the channel of the trending video
uploaderUrl: z.string(), // The URL of the channel of the trending video
uploaderVerified: z.boolean(), // Whether or not the channel of the trending video is verified
url: z.string(), // The URL of the trending video
views: z.number() // The number of views the trending video has
});
type Trending = z.infer<typeof TrendingModel>;
export default Trending;

@ -0,0 +1,49 @@
import { TrendingVideo } from "./typings/trending";
import InvidiousAdapter from "./adapters/invidious";
import PipedAdapter from "./adapters/piped";
import Adapter, { ApiType } from "./adapters";
export interface RemoteApi {
type: ApiType;
baseUrl: string;
score: number;
}
export default class Client {
private apis: RemoteApi[];
private adapters: Adapter[] = [InvidiousAdapter, PipedAdapter];
constructor(
apis: {
type: ApiType;
baseUrl: string;
}[]
) {
this.apis = apis.map((api) => ({ ...api, score: 0 }));
}
private getAdapterForApiType(apiType: ApiType): Adapter {
const adapter = this.adapters.find((adapter) => adapter.apiType == apiType);
if (adapter === undefined)
throw new Error(`Could not find an adapter with api type ${apiType}`);
return adapter;
}
private getBestApi(): RemoteApi {
const randomIndex = Math.floor(Math.random() * this.apis.length);
return this.apis[randomIndex];
}
public async getTrending(region: string): Promise<TrendingVideo[]> {
const api = this.getBestApi();
const adapter = this.getAdapterForApiType(api.type);
return await adapter.connect(api.baseUrl).getTrending(region);
}
}

@ -0,0 +1,3 @@
export interface Thumbnail {
url: string;
}

@ -0,0 +1,17 @@
import { Thumbnail } from "./thumbnail";
export interface TrendingVideo {
title: string;
thumbnails: Thumbnail[];
id: string;
author: {
name: string;
id: string;
};
/*
Duration in milliseconds.
*/
duration: number;
views: number;
uploaded: Date;
}

@ -0,0 +1,3 @@
import { FC, PropsWithChildren } from "react";
export type Component<P = unknown> = FC<PropsWithChildren<P>>;

@ -4893,6 +4893,11 @@ keyv@^4.5.3:
dependencies: dependencies:
json-buffer "3.0.1" json-buffer "3.0.1"
ky@^1.2.2:
version "1.2.2"
resolved "https://registry.yarnpkg.com/ky/-/ky-1.2.2.tgz#875b4132012ff7d8c12e5e884f35015676c436d2"
integrity sha512-gYA2QOI3uIaImJPJjaBbLCdvKHzwxsuB03s7PjrXmoO6tcn6k53rwYoSRgqrmVsEV6wFFegOXDVjABxFZ0aRSg==
language-subtag-registry@^0.3.20: language-subtag-registry@^0.3.20:
version "0.3.22" version "0.3.22"
resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d" resolved "https://registry.yarnpkg.com/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz#2e1500861b2e457eba7e7ae86877cbd08fa1fd1d"
@ -6680,3 +6685,8 @@ yocto-queue@^0.1.0:
version "0.1.0" version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
zod@^3.22.4:
version "3.22.4"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff"
integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==

Loading…
Cancel
Save