basic client that can fetch trending page
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"
|
||||||
|
}
|
@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
@ -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>>;
|
Loading…
Reference in new issue