basic search backend implemented
continuous-integration/drone/push Build is passing Details

nextui
Guus van Meerveld 1 month ago
parent ed53ae1ea1
commit c7dd2ddd12

@ -1,10 +1,12 @@
import { Suggestions } from "@/client/typings/search/suggestions";
import { VideoPreview } from "@/client/typings/trending";
import { Video } from "@/client/typings/video";
import { SearchResults } from "../typings/search";
export interface ConnectedAdapter {
getTrending(region: string): Promise<VideoPreview[]>;
getTrending(region: string): Promise<Video[]>;
getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string): Promise<SearchResults>;
}
export default interface Adapter {

@ -1,6 +1,6 @@
import ky from "ky";
import Trending, { TrendingModel } from "./typings/trending";
import Video, { VideoModel } from "./typings/video";
import Suggestions, { SuggestionsModel } from "./typings/search/suggestions";
import Adapter, { ApiType } from "@/client/adapters";
@ -8,6 +8,7 @@ import Adapter, { ApiType } from "@/client/adapters";
import Transformer from "./transformer";
import path from "path";
import Search, { SearchModel } from "./typings/search";
const apiPath = (...paths: string[]): string =>
path.join("api", "v1", ...paths);
@ -18,7 +19,7 @@ const getTrending = async (
baseUrl: string,
region?: string,
type?: TrendingVideoType
): Promise<Trending[]> => {
): Promise<Video[]> => {
const url = new URL(apiPath("trending"), baseUrl);
const searchParams = new URLSearchParams();
@ -33,7 +34,7 @@ const getTrending = async (
const json = await response.json();
const data = TrendingModel.array().parse(json);
const data = VideoModel.array().parse(json);
return data;
};
@ -55,17 +56,34 @@ const getSearchSuggestions = async (
return data;
};
const getSearch = async (baseUrl: string, query: string): Promise<Search> => {
const url = new URL(apiPath("search"), baseUrl);
const response = await ky.get(url, {
searchParams: { q: query }
});
const json = await response.json();
const data = SearchModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Invidious,
connect(url) {
return {
getTrending(region) {
return getTrending(url, region).then(Transformer.trending);
return getTrending(url, region).then(Transformer.videos);
},
getSearchSuggestions(query) {
return getSearchSuggestions(url, query).then(Transformer.suggestions);
},
getSearch(query) {
return getSearch(url, query).then(Transformer.search);
}
};
}

@ -1,37 +1,98 @@
import { VideoPreview } from "@/client/typings/trending";
import { Video } from "@/client/typings/video";
import { Suggestions } from "@/client/typings/search/suggestions";
import {
ChannelResult,
PlaylistResult,
SearchResults,
VideoResult
} from "@/client/typings/search";
import InvidiousTrending from "./typings/trending";
import InvidiousVideo from "./typings/video";
import InvidiousSuggestions from "./typings/search/suggestions";
import { Suggestions } from "@/client/typings/search/suggestions";
import InvidiousSearch from "./typings/search";
import InvidiousThumbnail from "./typings/thumbnail";
export default class Transformer {
public static trending(data: InvidiousTrending[]): VideoPreview[] {
return data.map((video) => {
const thumbnail = video.videoThumbnails.find(
(thumbnail) =>
thumbnail.quality == "default" ||
thumbnail.quality == "medium" ||
thumbnail.quality == "middle"
private static findBestThumbnail(
thumbnails: InvidiousThumbnail[]
): string | null {
const thumbnail = thumbnails.find(
(thumbnail) =>
thumbnail.quality == "default" ||
thumbnail.quality == "medium" ||
thumbnail.quality == "middle"
);
return thumbnail?.url ?? null;
}
public static video(data: InvidiousVideo): Video {
const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails);
if (thumbnail === null)
throw new Error(
`Invidious: Missing thumbnail for video with id ${data.videoId}`
);
if (thumbnail === undefined)
throw new Error(
`Invidious: Missing thumbnail for video with id ${video.videoId}`
);
return {
author: { id: video.authorId, name: video.author },
duration: video.lengthSeconds * 1000,
id: video.videoId,
title: video.title,
thumbnail: thumbnail.url,
uploaded: new Date(video.published * 1000 ?? 0),
views: video.viewCount
};
});
return {
author: { id: data.authorId, name: data.author },
duration: data.lengthSeconds * 1000,
description: data.description,
live: data.liveNow,
id: data.videoId,
title: data.title,
thumbnail: thumbnail,
uploaded: new Date(data.published * 1000 ?? 0),
views: data.viewCount
};
}
public static videos(data: InvidiousVideo[]): Video[] {
return data.map(Transformer.video);
}
public static suggestions(data: InvidiousSuggestions): Suggestions {
return data.suggestions;
}
public static search(data: InvidiousSearch): SearchResults {
return data.map((result) => {
switch (result.type) {
case "video":
const video: VideoResult = {
...Transformer.video(result),
type: "video"
};
return video;
case "channel":
const channel: ChannelResult = {
type: "channel",
name: result.author,
id: result.authorId,
thumbnail: result.authorThumbnails[0].url,
subscribers: result.subCount,
videos: result.videoCount,
description: result.description
};
return channel;
case "playlist":
const playlist: PlaylistResult = {
type: "playlist",
title: result.title,
author: {
name: result.author,
id: result.authorId
},
id: result.playlistId,
numberOfVideos: result.videoCount
};
return playlist;
}
});
}
}

@ -0,0 +1,54 @@
import z from "zod";
import { ThumbnailModel } from "../thumbnail";
import { VideoModel } from "../video";
export const VideoResultModel = z
.object({
type: z.literal("video")
})
.and(VideoModel);
export const ChannelResultModel = z.object({
type: z.literal("channel"),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
authorThumbnails: z
.object({
url: z.string(),
width: z.number(),
height: z.number()
})
.array(),
autoGenerated: z.boolean(),
subCount: z.number(),
videoCount: z.number(),
description: z.string(),
descriptionHtml: z.string()
});
export const PlaylistResultModel = z.object({
type: z.literal("playlist"),
title: z.string(),
playlistId: z.string(),
playlistThumbnail: z.string().url(),
author: z.string(),
authorId: z.string(),
authorUrl: z.string(),
authorVerified: z.boolean(),
videoCount: z.number(),
videos: z.object({
title: z.string(),
videoId: z.string(),
lengthSeconds: z.number(),
videoThumbnails: ThumbnailModel.array()
})
});
export const SearchModel = z
.union([VideoResultModel, ChannelResultModel, PlaylistResultModel])
.array();
type Search = z.infer<typeof SearchModel>;
export default Search;

@ -1,7 +1,7 @@
import z from "zod";
import { ThumbnailModel } from "./thumbnail";
export const TrendingModel = z.object({
export const VideoModel = z.object({
title: z.string(),
videoId: z.string(),
videoThumbnails: ThumbnailModel.array(),
@ -23,6 +23,6 @@ export const TrendingModel = z.object({
premium: z.boolean()
});
type Trending = z.infer<typeof TrendingModel>;
type Video = z.infer<typeof VideoModel>;
export default Trending;
export default Video;

@ -3,15 +3,16 @@ import ky from "ky";
import Adapter, { ApiType } from "@/client/adapters";
import Trending, { TrendingModel } from "./typings/trending";
import Video, { VideoModel } from "./typings/video";
import Transformer from "./transformer";
import { Suggestions } from "@/client/typings/search/suggestions";
import Search, { SearchModel } from "./typings/search";
const getTrending = async (
apiBaseUrl: string,
region = "US"
): Promise<Trending[]> => {
): Promise<Video[]> => {
const url = new URL("/trending", apiBaseUrl);
const response = await ky.get(url, {
@ -20,7 +21,7 @@ const getTrending = async (
const json = await response.json();
const data = TrendingModel.array().parse(json);
const data = VideoModel.array().parse(json);
return data;
};
@ -42,17 +43,37 @@ const getSearchSuggestions = async (
return data;
};
const getSearch = async (
apiBaseUrl: string,
query: string
): Promise<Search> => {
const url = new URL("search", apiBaseUrl);
const response = await ky.get(url, {
searchParams: { q: query, filter: "all" }
});
const json = await response.json();
const data = SearchModel.parse(json);
return data;
};
const adapter: Adapter = {
apiType: ApiType.Piped,
connect(url) {
return {
getTrending(region) {
return getTrending(url, region).then(Transformer.trending);
return getTrending(url, region).then(Transformer.videos);
},
getSearchSuggestions(query) {
return getSearchSuggestions(url, query);
},
getSearch(query) {
return getSearch(url, query).then(Transformer.search);
}
};
}

@ -1,35 +1,91 @@
import { VideoPreview } from "@/client/typings/trending";
import { Video } from "@/client/typings/video";
import {
ChannelResult,
PlaylistResult,
SearchResults,
VideoResult
} from "@/client/typings/search";
import PipedTrending from "./typings/trending";
const videoIdRegex = /\/watch\?v=(.+)/;
const channelIdRegex = /\/channel\/(.+)/;
import PipedVideo from "./typings/video";
import PipedSearch from "./typings/search";
import {
parseChannelIdFromUrl,
parseVideoIdFromUrl
} from "@/utils/parseIdFromUrl";
export default class Transformer {
public static trending(data: PipedTrending[]): VideoPreview[] {
return data.map((video) => {
const videoIdMatch = video.url.match(videoIdRegex);
public static video(data: PipedVideo): Video {
const videoId = parseVideoIdFromUrl(data.url);
if (videoId === null) throw new Error("Piped: Missing video id");
const channelId = parseChannelIdFromUrl(data.uploaderUrl);
if (channelId === null) throw new Error("Piped: Missing video channelId");
return {
duration: data.duration * 1000,
views: data.views,
id: videoId,
uploaded: new Date(data.uploaded),
thumbnail: data.thumbnail,
title: data.title,
description: "",
live: false,
author: { id: channelId, name: data.uploaderName }
};
}
public static videos(data: PipedVideo[]): Video[] {
return data.map(Transformer.video);
}
public static search(data: PipedSearch): SearchResults {
return data.items.map((result) => {
switch (result.type) {
case "stream":
const video: VideoResult = {
...Transformer.video(result),
type: "video"
};
return video;
case "channel":
const id = parseChannelIdFromUrl(result.url);
if (id === null) throw new Error("Piped: Missing channelId");
const videoId = videoIdMatch !== null ? videoIdMatch[1] : null;
const channel: ChannelResult = {
type: "channel",
name: result.name,
id: id,
thumbnail: result.thumbnail,
subscribers: result.subscribers,
videos: result.videos,
description: result.description
};
if (videoId === null) throw new Error("Piped: Missing trending video id");
return channel;
const channelIdMatch = video.uploaderUrl.match(channelIdRegex);
case "playlist":
const channelId = parseChannelIdFromUrl(result.uploaderUrl);
const channelId = channelIdMatch !== null ? channelIdMatch[1] : null;
if (channelId === null) throw new Error("Piped: Missing channelId");
if (channelId === null)
throw new Error("Piped: Missing trending channelId");
const playlist: PlaylistResult = {
type: "playlist",
title: result.name,
author: {
name: result.uploaderName,
id: channelId
},
id: result.url,
numberOfVideos: result.videos
};
return {
duration: video.duration * 1000,
views: video.views,
id: videoId,
uploaded: new Date(video.uploaded),
thumbnail: video.thumbnail,
title: video.title,
author: { id: channelId, name: video.uploaderName }
};
return playlist;
}
});
}
}

@ -0,0 +1,44 @@
import z from "zod";
import { VideoModel } from "../video";
export const VideoResultModel = z
.object({
type: z.literal("stream")
})
.and(VideoModel);
export const ChannelResultModel = z.object({
type: z.literal("channel"),
url: z.string(),
name: z.string(),
thumbnail: z.string().url(),
description: z.string(),
subscribers: z.number(),
videos: z.number(),
verified: z.boolean()
});
export const PlaylistResultModel = z.object({
type: z.literal("playlist"),
url: z.string(),
name: z.string(),
thumbnail: z.string().url(),
uploaderName: z.string(),
uploaderUrl: z.string(),
uploaderVerified: z.boolean(),
playlistType: z.string(),
videos: z.number()
});
export const SearchModel = z.object({
items: z
.union([VideoResultModel, ChannelResultModel, PlaylistResultModel])
.array(),
nextpage: z.string(),
suggestion: z.string(),
corrected: z.boolean()
});
type Search = z.infer<typeof SearchModel>;
export default Search;

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

@ -1,10 +1,11 @@
import { VideoPreview } from "./typings/videoPreview";
import { Video } from "./typings/video";
import InvidiousAdapter from "./adapters/invidious";
import PipedAdapter from "./adapters/piped";
import Adapter, { ApiType, ConnectedAdapter } from "./adapters";
import { Suggestions } from "./typings/search/suggestions";
import { SearchResults } from "./typings/search";
export interface RemoteApi {
type: ApiType;
@ -48,7 +49,7 @@ export default class Client {
return adapter.connect(api.baseUrl);
}
public async getTrending(region: string): Promise<VideoPreview[]> {
public async getTrending(region: string): Promise<Video[]> {
const adapter = this.getBestAdapter();
return await adapter.getTrending(region);
@ -59,4 +60,10 @@ export default class Client {
return await adapter.getSearchSuggestions(query);
}
public async getSearch(query: string): Promise<SearchResults> {
const adapter = this.getBestAdapter();
return await adapter.getSearch(query);
}
}

@ -0,0 +1,26 @@
import { Video } from "../video";
export type VideoResult = Video & { type: "video" };
export interface ChannelResult {
type: "channel";
name: string;
id: string;
thumbnail: string;
subscribers: number;
videos: number;
description: string;
}
export interface PlaylistResult {
type: "playlist";
title: string;
id: string;
author: {
name: string;
id: string;
};
numberOfVideos: number;
}
export type SearchResults = (VideoResult | ChannelResult | PlaylistResult)[];

@ -1,15 +1,17 @@
export interface VideoPreview {
export interface Video {
title: string;
thumbnail: string;
id: string;
author: {
name: string;
id: string;
};
thumbnail: string;
description: string;
/*
Duration in milliseconds.
*/
duration: number;
views: number;
uploaded: Date;
live: boolean;
}

@ -1,4 +1,4 @@
import { VideoPreview } from "@/client/typings/videoPreview";
import { Video as VideoProps } from "@/client/typings/video";
import { Component } from "@/typings/component";
import { Card, CardFooter, CardBody } from "@nextui-org/card";
import { Image } from "@nextui-org/image";
@ -11,7 +11,7 @@ import formatUploadedTime from "@/utils/formatUploadedTime";
import { Tooltip } from "@nextui-org/tooltip";
import { ContextMenuItem } from "@/typings/contextMenu";
export const Video: Component<{ data: VideoPreview }> = ({ data: video }) => {
export const Video: Component<{ data: VideoProps }> = ({ data: video }) => {
const url = `/watch?v=${video.id}`;
const channelUrl = `/channel/${video.author.id}`;

@ -0,0 +1,19 @@
const videoIdRegex = /\/watch\?v=(.+)/;
export const parseVideoIdFromUrl = (url: string): string | null => {
const videoIdMatch = url.match(videoIdRegex);
const videoId = videoIdMatch !== null ? videoIdMatch[1] : null;
return videoId;
};
const channelIdRegex = /\/channel\/(.+)/;
export const parseChannelIdFromUrl = (url: string): string | null => {
const channelIdMatch = url.match(channelIdRegex);
const channelId = channelIdMatch !== null ? channelIdMatch[1] : null;
return channelId;
};
Loading…
Cancel
Save