diff --git a/bruno/Invidious/Trending.bru b/bruno/Invidious/Trending.bru
new file mode 100644
index 0000000..9e42450
--- /dev/null
+++ b/bruno/Invidious/Trending.bru
@@ -0,0 +1,11 @@
+meta {
+ name: Trending
+ type: http
+ seq: 1
+}
+
+get {
+ url: https://invidious.drgns.space/api/v1/trending
+ body: none
+ auth: none
+}
diff --git a/bruno/Piped/Trending.bru b/bruno/Piped/Trending.bru
new file mode 100644
index 0000000..83e7269
--- /dev/null
+++ b/bruno/Piped/Trending.bru
@@ -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
+}
diff --git a/bruno/bruno.json b/bruno/bruno.json
new file mode 100644
index 0000000..3160fcf
--- /dev/null
+++ b/bruno/bruno.json
@@ -0,0 +1,5 @@
+{
+ "version": "1",
+ "name": "MaterialTube",
+ "type": "collection"
+}
\ No newline at end of file
diff --git a/package.json b/package.json
index 50f633e..3800c69 100644
--- a/package.json
+++ b/package.json
@@ -11,10 +11,12 @@
"dependencies": {
"@nextui-org/react": "^2.2.10",
"framer-motion": "^11.0.12",
+ "ky": "^1.2.2",
"next": "14.1.3",
"next-pwa": "^5.6.0",
"react": "^18",
- "react-dom": "^18"
+ "react-dom": "^18",
+ "zod": "^3.22.4"
},
"devDependencies": {
"@types/next-pwa": "^5.6.9",
diff --git a/src/app/(trending)/Video.tsx b/src/app/(trending)/Video.tsx
new file mode 100644
index 0000000..049892d
--- /dev/null
+++ b/src/app/(trending)/Video.tsx
@@ -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 <>>;
+};
diff --git a/src/app/page.tsx b/src/app/(trending)/page.tsx
similarity index 69%
rename from src/app/page.tsx
rename to src/app/(trending)/page.tsx
index e7d4c01..4068cd6 100644
--- a/src/app/page.tsx
+++ b/src/app/(trending)/page.tsx
@@ -1,9 +1,10 @@
import { Button } from "@nextui-org/button";
+import { Video } from "./Video";
export default function Home() {
return (
<>
-
+
>
);
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 86ab459..eb40b6d 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,17 +1,16 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "./providers";
+import { Component } from "@/typings/component";
export const metadata: Metadata = {
- title: "Create Next App",
- description: "Generated by create next app"
+ title: "MaterialTube client",
+ description:
+ "MaterialTube is a beautiful and elegant web client for Invidious servers, built using Next.js and NextUI.",
+ applicationName: "MaterialTube"
};
-export default function RootLayout({
- children
-}: Readonly<{
- children: React.ReactNode;
-}>) {
+const RootLayout: Component = ({ children }) => {
return (
@@ -19,4 +18,6 @@ export default function RootLayout({
);
-}
+};
+
+export default RootLayout;
diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts
new file mode 100644
index 0000000..55d1587
--- /dev/null
+++ b/src/client/adapters/index.ts
@@ -0,0 +1,16 @@
+import { TrendingVideo } from "../typings/trending";
+
+export interface ConnectedAdapter {
+ getTrending(region: string): Promise;
+}
+
+export default interface Adapter {
+ apiType: ApiType;
+
+ connect(url: string): ConnectedAdapter;
+}
+
+export enum ApiType {
+ Piped,
+ Invidious
+}
diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts
new file mode 100644
index 0000000..d23d6e0
--- /dev/null
+++ b/src/client/adapters/invidious/index.ts
@@ -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 => {
+ 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;
diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts
new file mode 100644
index 0000000..3d7f5f9
--- /dev/null
+++ b/src/client/adapters/invidious/transformer.ts
@@ -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
+ }));
+ }
+}
diff --git a/src/client/adapters/invidious/typings/thumbnail.ts b/src/client/adapters/invidious/typings/thumbnail.ts
new file mode 100644
index 0000000..bb08c89
--- /dev/null
+++ b/src/client/adapters/invidious/typings/thumbnail.ts
@@ -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;
+
+export default Thumbnail;
diff --git a/src/client/adapters/invidious/typings/trending.ts b/src/client/adapters/invidious/typings/trending.ts
new file mode 100644
index 0000000..ccf6b22
--- /dev/null
+++ b/src/client/adapters/invidious/typings/trending.ts
@@ -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;
+
+export default Trending;
diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts
new file mode 100644
index 0000000..1897a0a
--- /dev/null
+++ b/src/client/adapters/piped/index.ts
@@ -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 => {
+ 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;
diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts
new file mode 100644
index 0000000..833a492
--- /dev/null
+++ b/src/client/adapters/piped/transformer.ts
@@ -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 }
+ };
+ });
+ }
+}
diff --git a/src/client/adapters/piped/typings/trending.ts b/src/client/adapters/piped/typings/trending.ts
new file mode 100644
index 0000000..1e5ca9f
--- /dev/null
+++ b/src/client/adapters/piped/typings/trending.ts
@@ -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;
+
+export default Trending;
diff --git a/src/client/index.ts b/src/client/index.ts
new file mode 100644
index 0000000..e8fc346
--- /dev/null
+++ b/src/client/index.ts
@@ -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 {
+ const api = this.getBestApi();
+
+ const adapter = this.getAdapterForApiType(api.type);
+
+ return await adapter.connect(api.baseUrl).getTrending(region);
+ }
+}
diff --git a/src/client/typings/thumbnail.ts b/src/client/typings/thumbnail.ts
new file mode 100644
index 0000000..0867598
--- /dev/null
+++ b/src/client/typings/thumbnail.ts
@@ -0,0 +1,3 @@
+export interface Thumbnail {
+ url: string;
+}
diff --git a/src/client/typings/trending.ts b/src/client/typings/trending.ts
new file mode 100644
index 0000000..8085205
--- /dev/null
+++ b/src/client/typings/trending.ts
@@ -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;
+}
diff --git a/src/typings/component.ts b/src/typings/component.ts
new file mode 100644
index 0000000..0764147
--- /dev/null
+++ b/src/typings/component.ts
@@ -0,0 +1,3 @@
+import { FC, PropsWithChildren } from "react";
+
+export type Component = FC>;
diff --git a/yarn.lock b/yarn.lock
index 64fbbe2..0f5d44d 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -4893,6 +4893,11 @@ keyv@^4.5.3:
dependencies:
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:
version "0.3.22"
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"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
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==