diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts index cda4854..33c5c5a 100644 --- a/src/client/adapters/index.ts +++ b/src/client/adapters/index.ts @@ -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; + getTrending(region: string): Promise; getSearchSuggestions(query: string): Promise; + getSearch(query: string): Promise; } export default interface Adapter { diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts index 6383650..3debd00 100644 --- a/src/client/adapters/invidious/index.ts +++ b/src/client/adapters/invidious/index.ts @@ -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 => { +): Promise => { 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 => { + 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); } }; } diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts index e79b5aa..90c82cd 100644 --- a/src/client/adapters/invidious/transformer.ts +++ b/src/client/adapters/invidious/transformer.ts @@ -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; + } + }); + } } diff --git a/src/client/adapters/invidious/typings/search/index.ts b/src/client/adapters/invidious/typings/search/index.ts new file mode 100644 index 0000000..18e6584 --- /dev/null +++ b/src/client/adapters/invidious/typings/search/index.ts @@ -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; + +export default Search; diff --git a/src/client/adapters/invidious/typings/trending.ts b/src/client/adapters/invidious/typings/video.ts similarity index 81% rename from src/client/adapters/invidious/typings/trending.ts rename to src/client/adapters/invidious/typings/video.ts index ccf6b22..37f44c7 100644 --- a/src/client/adapters/invidious/typings/trending.ts +++ b/src/client/adapters/invidious/typings/video.ts @@ -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; +type Video = z.infer; -export default Trending; +export default Video; diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts index d9d711c..d0a00c2 100644 --- a/src/client/adapters/piped/index.ts +++ b/src/client/adapters/piped/index.ts @@ -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 => { +): Promise => { 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 => { + 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); } }; } diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts index c9219f6..8c200d3 100644 --- a/src/client/adapters/piped/transformer.ts +++ b/src/client/adapters/piped/transformer.ts @@ -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; + } }); } } diff --git a/src/client/adapters/piped/typings/search/index.ts b/src/client/adapters/piped/typings/search/index.ts new file mode 100644 index 0000000..94c9bc2 --- /dev/null +++ b/src/client/adapters/piped/typings/search/index.ts @@ -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; + +export default Search; diff --git a/src/client/adapters/piped/typings/trending.ts b/src/client/adapters/piped/typings/trending.ts deleted file mode 100644 index 1e5ca9f..0000000 --- a/src/client/adapters/piped/typings/trending.ts +++ /dev/null @@ -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; - -export default Trending; diff --git a/src/client/adapters/piped/typings/video.ts b/src/client/adapters/piped/typings/video.ts new file mode 100644 index 0000000..4427378 --- /dev/null +++ b/src/client/adapters/piped/typings/video.ts @@ -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; + +export default Video; diff --git a/src/client/index.ts b/src/client/index.ts index 10cd019..a0f309f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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 { + public async getTrending(region: string): Promise { 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 { + const adapter = this.getBestAdapter(); + + return await adapter.getSearch(query); + } } diff --git a/src/client/typings/search/index.ts b/src/client/typings/search/index.ts new file mode 100644 index 0000000..452ba9c --- /dev/null +++ b/src/client/typings/search/index.ts @@ -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)[]; diff --git a/src/client/typings/videoPreview.ts b/src/client/typings/video.ts similarity index 74% rename from src/client/typings/videoPreview.ts rename to src/client/typings/video.ts index bc9750f..81c3859 100644 --- a/src/client/typings/videoPreview.ts +++ b/src/client/typings/video.ts @@ -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; } diff --git a/src/components/Video.tsx b/src/components/Video.tsx index f64b3e6..b8d42b5 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -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}`; diff --git a/src/utils/parseIdFromUrl.ts b/src/utils/parseIdFromUrl.ts new file mode 100644 index 0000000..874596a --- /dev/null +++ b/src/utils/parseIdFromUrl.ts @@ -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; +};