From f24c17d35ea2d3c1f7c9685af4c5a8d47cb143c9 Mon Sep 17 00:00:00 2001 From: Guus van Meerveld Date: Fri, 22 Mar 2024 13:10:46 +0100 Subject: [PATCH] watch: started work on stream backend --- bruno/Invidious/Stream.bru | 11 ++ bruno/Piped/Stream.bru | 11 ++ src/app/providers/index.tsx | 4 +- src/app/results/Channel.tsx | 4 +- src/app/results/Playlist.tsx | 4 +- src/app/results/Video.tsx | 5 +- src/app/watch/Watch.tsx | 13 ++ src/client/adapters/index.ts | 3 + src/client/adapters/invidious/index.ts | 17 +++ src/client/adapters/invidious/transformer.ts | 73 +++++++-- .../invidious/typings/search/index.ts | 10 +- .../adapters/invidious/typings/storyboard.ts | 15 ++ .../adapters/invidious/typings/stream.ts | 104 +++++++++++++ .../adapters/invidious/typings/thumbnail.ts | 10 +- .../adapters/invidious/typings/video.ts | 6 +- src/client/adapters/piped/index.ts | 20 +++ src/client/adapters/piped/transformer.ts | 141 +++++++++++------- src/client/adapters/piped/typings/item.ts | 41 +++++ .../adapters/piped/typings/search/index.ts | 34 +---- src/client/adapters/piped/typings/stream.ts | 100 +++++++++++++ src/client/index.ts | 7 + src/client/typings/item.ts | 33 ++++ src/client/typings/search/index.ts | 36 +---- src/client/typings/stream.ts | 11 ++ src/components/Video.tsx | 10 +- src/hooks/useClient.ts | 2 +- src/utils/urls.ts | 4 + 27 files changed, 575 insertions(+), 154 deletions(-) create mode 100644 bruno/Invidious/Stream.bru create mode 100644 bruno/Piped/Stream.bru create mode 100644 src/client/adapters/invidious/typings/storyboard.ts create mode 100644 src/client/adapters/invidious/typings/stream.ts create mode 100644 src/client/adapters/piped/typings/item.ts create mode 100644 src/client/adapters/piped/typings/stream.ts create mode 100644 src/client/typings/item.ts create mode 100644 src/client/typings/stream.ts create mode 100644 src/utils/urls.ts diff --git a/bruno/Invidious/Stream.bru b/bruno/Invidious/Stream.bru new file mode 100644 index 0000000..23346a4 --- /dev/null +++ b/bruno/Invidious/Stream.bru @@ -0,0 +1,11 @@ +meta { + name: Stream + type: http + seq: 2 +} + +get { + url: https://invidious.drgns.space/api/v1/videos/CcHevgjAnV0 + body: none + auth: none +} diff --git a/bruno/Piped/Stream.bru b/bruno/Piped/Stream.bru new file mode 100644 index 0000000..7c48de6 --- /dev/null +++ b/bruno/Piped/Stream.bru @@ -0,0 +1,11 @@ +meta { + name: Stream + type: http + seq: 2 +} + +get { + url: https://pipedapi.kavin.rocks/streams/CcHevgjAnV0 + body: none + auth: none +} diff --git a/src/app/providers/index.tsx b/src/app/providers/index.tsx index 09107a2..32b3791 100644 --- a/src/app/providers/index.tsx +++ b/src/app/providers/index.tsx @@ -6,7 +6,9 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ContextMenuProvider } from "./ContextMenuProvider"; export function Providers({ children }: { children: React.ReactNode }) { - const queryClient = new QueryClient(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } } + }); return ( diff --git a/src/app/results/Channel.tsx b/src/app/results/Channel.tsx index 0968c07..fa41e90 100644 --- a/src/app/results/Channel.tsx +++ b/src/app/results/Channel.tsx @@ -2,14 +2,14 @@ import { Component } from "@/typings/component"; -import { ChannelResult as ChannelProps } from "@/client/typings/search"; +import { ChannelItem } from "@/client/typings/item"; import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; import Link from "next/link"; import NextImage from "next/image"; import formatBigNumber from "@/utils/formatBigNumber"; -export const Channel: Component<{ data: ChannelProps }> = ({ data }) => { +export const Channel: Component<{ data: ChannelItem }> = ({ data }) => { const url = `/channel/${data.id}`; const imageSize = 200; diff --git a/src/app/results/Playlist.tsx b/src/app/results/Playlist.tsx index e079bc3..c574654 100644 --- a/src/app/results/Playlist.tsx +++ b/src/app/results/Playlist.tsx @@ -1,6 +1,6 @@ "use client"; -import { PlaylistResult as PlaylistProps } from "@/client/typings/search"; +import { PlaylistItem } from "@/client/typings/item"; import { Component } from "@/typings/component"; import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; @@ -9,7 +9,7 @@ import NextImage from "next/image"; import { Link } from "@nextui-org/link"; import { videoSize } from "@/utils/videoSize"; -export const Playlist: Component<{ data: PlaylistProps }> = ({ data }) => { +export const Playlist: Component<{ data: PlaylistItem }> = ({ data }) => { const url = `/playlist/${data.id}`; const channelUrl = `/channel/${data.author.id}`; diff --git a/src/app/results/Video.tsx b/src/app/results/Video.tsx index dd49593..c0feb51 100644 --- a/src/app/results/Video.tsx +++ b/src/app/results/Video.tsx @@ -1,12 +1,11 @@ "use client"; import { Component } from "@/typings/component"; -import { VideoResult as VideoProps } from "@/client/typings/search"; +import { VideoItem } from "@/client/typings/item"; import { Card, CardBody } from "@nextui-org/card"; import { Image } from "@nextui-org/image"; import NextImage from "next/image"; -import { useMemo } from "react"; import formatBigNumber from "@/utils/formatBigNumber"; import formatUploadedTime from "@/utils/formatUploadedTime"; import { Link } from "@nextui-org/link"; @@ -14,7 +13,7 @@ import NextLink from "next/link"; import formatDuration from "@/utils/formatDuration"; import { videoSize } from "@/utils/videoSize"; -export const Video: Component<{ data: VideoProps }> = ({ data }) => { +export const Video: Component<{ data: VideoItem }> = ({ data }) => { const url = `/watch?v=${data.id}`; const channelUrl = `/channel/${data.author.id}`; diff --git a/src/app/watch/Watch.tsx b/src/app/watch/Watch.tsx index 83edc90..314f882 100644 --- a/src/app/watch/Watch.tsx +++ b/src/app/watch/Watch.tsx @@ -1,12 +1,25 @@ "use client"; +import { useClient } from "@/hooks/useClient"; import { Component } from "@/typings/component"; +import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "next/navigation"; export const Watch: Component = () => { + const client = useClient(); + const searchParams = useSearchParams(); const videoId = searchParams.get("v"); + const { data, error } = useQuery({ + queryKey: ["watch", videoId], + queryFn: () => { + return client.getStream(videoId ?? ""); + } + }); + + console.log(data, error); + return <>; }; diff --git a/src/client/adapters/index.ts b/src/client/adapters/index.ts index 8388663..2c59aad 100644 --- a/src/client/adapters/index.ts +++ b/src/client/adapters/index.ts @@ -2,12 +2,15 @@ import { Suggestions } from "@/client/typings/search/suggestions"; import { Video } from "@/client/typings/video"; import { SearchResults } from "@/client/typings/search"; import { SearchOptions } from "@/client/typings/search/options"; +import { Stream } from "@/client/typings/search/stream"; export interface ConnectedAdapter { getTrending(region: string): Promise; getSearchSuggestions(query: string): Promise; getSearch(query: string, options?: SearchOptions): Promise; + + getStream(videoId: string): Promise; } export default interface Adapter { diff --git a/src/client/adapters/invidious/index.ts b/src/client/adapters/invidious/index.ts index f222836..b748096 100644 --- a/src/client/adapters/invidious/index.ts +++ b/src/client/adapters/invidious/index.ts @@ -9,6 +9,7 @@ import Transformer from "./transformer"; import path from "path"; import Search, { SearchModel } from "./typings/search"; +import Stream, { StreamModel } from "./typings/stream"; const apiPath = (...paths: string[]): string => path.join("api", "v1", ...paths); @@ -83,6 +84,18 @@ const getSearch = async ( return data; }; +const getVideo = async (baseUrl: string, videoId: string): Promise => { + const url = new URL(apiPath("videos", videoId), baseUrl); + + const response = await ky.get(url); + + const json = await response.json(); + + const data = StreamModel.parse(json); + + return data; +}; + const adapter: Adapter = { apiType: ApiType.Invidious, @@ -104,6 +117,10 @@ const adapter: Adapter = { }).then(Transformer.search); return { items: items, nextCursor: (page + 1).toString() }; + }, + + async getStream(videoId) { + return getVideo(url, videoId).then(Transformer.stream); } }; } diff --git a/src/client/adapters/invidious/transformer.ts b/src/client/adapters/invidious/transformer.ts index 10579ee..b576061 100644 --- a/src/client/adapters/invidious/transformer.ts +++ b/src/client/adapters/invidious/transformer.ts @@ -1,16 +1,20 @@ import { Video } from "@/client/typings/video"; import { Suggestions } from "@/client/typings/search/suggestions"; -import { - ChannelResult, - PlaylistResult, - SearchItems, - VideoResult -} from "@/client/typings/search"; +import { Stream } from "@/client/typings/stream"; import InvidiousVideo from "./typings/video"; import InvidiousSuggestions from "./typings/search/suggestions"; import InvidiousSearch from "./typings/search"; import InvidiousThumbnail from "./typings/thumbnail"; +import InvidiousStream, { + RecommendedVideo as InvidiousRecommendedVideo +} from "./typings/stream"; +import { + ChannelItem, + Item, + PlaylistItem, + VideoItem +} from "@/client/typings/item"; export default class Transformer { private static findBestThumbnail( @@ -27,6 +31,27 @@ export default class Transformer { return thumbnail?.url ?? null; } + private static recommendedVideo( + data: InvidiousRecommendedVideo + ): RecommendedVideo { + const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails); + + if (thumbnail === null) + throw new Error( + `Invidious: Missing thumbnail for video with id ${data.videoId}` + ); + + return { + author: { id: data.authorId, name: data.author }, + duration: data.lengthSeconds * 1000, + live: data.liveNow, + id: data.videoId, + title: data.title, + thumbnail: thumbnail, + views: data.viewCount + }; + } + public static video(data: InvidiousVideo): Video { const thumbnail = Transformer.findBestThumbnail(data.videoThumbnails); @@ -56,11 +81,11 @@ export default class Transformer { return data.suggestions; } - public static search(data: InvidiousSearch): SearchItems { + public static search(data: InvidiousSearch): Item[] { return data.map((result) => { switch (result.type) { case "video": - const video: VideoResult = { + const video: VideoItem = { ...Transformer.video(result), type: "video" }; @@ -68,7 +93,7 @@ export default class Transformer { return video; case "channel": - const channel: ChannelResult = { + const channel: ChannelItem = { type: "channel", name: result.author, id: result.authorId, @@ -81,7 +106,7 @@ export default class Transformer { return channel; case "playlist": - const playlist: PlaylistResult = { + const playlist: PlaylistItem = { type: "playlist", title: result.title, author: { @@ -113,4 +138,32 @@ export default class Transformer { } }); } + + public static stream(stream: InvidiousStream): Stream { + const thumbnail = Transformer.findBestThumbnail(stream.videoThumbnails); + + if (thumbnail === null) + throw new Error( + `Invidious: Missing thumbnail for video with id ${stream.videoId}` + ); + + return { + category: stream.genre, + dislikes: stream.dislikeCount, + likes: stream.likeCount, + keywords: stream.keywords, + related: stream.recommendedVideos.map(Transformer.recommendedVideo), + video: { + author: { id: stream.authorId, name: stream.author }, + description: stream.description, + duration: stream.lengthSeconds * 1000, + id: stream.videoId, + live: stream.liveNow, + thumbnail: thumbnail, + title: stream.title, + uploaded: new Date(stream.published * 1000), + views: stream.viewCount + } + }; + } } diff --git a/src/client/adapters/invidious/typings/search/index.ts b/src/client/adapters/invidious/typings/search/index.ts index d32eed4..77e4df5 100644 --- a/src/client/adapters/invidious/typings/search/index.ts +++ b/src/client/adapters/invidious/typings/search/index.ts @@ -1,5 +1,5 @@ import z from "zod"; -import { ThumbnailModel } from "../thumbnail"; +import { AuthorThumbnailModel, ThumbnailModel } from "../thumbnail"; import { VideoModel } from "../video"; export const VideoResultModel = z @@ -13,13 +13,7 @@ export const ChannelResultModel = z.object({ author: z.string(), authorId: z.string(), authorUrl: z.string(), - authorThumbnails: z - .object({ - url: z.string(), - width: z.number(), - height: z.number() - }) - .array(), + authorThumbnails: AuthorThumbnailModel.array(), autoGenerated: z.boolean(), subCount: z.number(), videoCount: z.number(), diff --git a/src/client/adapters/invidious/typings/storyboard.ts b/src/client/adapters/invidious/typings/storyboard.ts new file mode 100644 index 0000000..cc0c028 --- /dev/null +++ b/src/client/adapters/invidious/typings/storyboard.ts @@ -0,0 +1,15 @@ +import z from "zod"; + +export const StoryboardModel = z.object({ + url: z.string(), + templateUrl: z.string().url(), + width: z.number(), + height: z.number(), + count: z.number(), + interval: z.number(), + storyboardWidth: z.number(), + storyboardHeight: z.number(), + storyboardCount: z.number() +}); + +export type Storyboard = z.infer; diff --git a/src/client/adapters/invidious/typings/stream.ts b/src/client/adapters/invidious/typings/stream.ts new file mode 100644 index 0000000..b55db4c --- /dev/null +++ b/src/client/adapters/invidious/typings/stream.ts @@ -0,0 +1,104 @@ +import z from "zod"; +import { AuthorThumbnailModel, ThumbnailModel } from "./thumbnail"; +import { StoryboardModel } from "./storyboard"; +import { VideoModel } from "./video"; + +export const AdaptiveFormatModel = z.object({ + index: z.string(), + bitrate: z.string(), + init: z.string(), + url: z.string().url(), + itag: z.string(), + type: z.string(), + clen: z.string(), + lmt: z.string(), + projectionType: z.number().or(z.string()), + container: z.string().optional(), + encoding: z.string().optional(), + qualityLabel: z.string().optional(), + resolution: z.string().optional(), + audioQuality: z.string().optional(), + audioSampleRate: z.number().optional(), + audioChannels: z.number().optional() +}); + +export const FormatStreamModel = z.object({ + url: z.string().url(), + itag: z.string(), + type: z.string(), + quality: z.string(), + fps: z.number(), + container: z.string(), + encoding: z.string(), + resolution: z.string(), + qualityLabel: z.string(), + size: z.string() +}); + +export const CaptionModel = z.object({ + label: z.string(), + language_code: z.string(), + url: z.string() +}); + +export const RecommendedVideoModel = 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(), + + liveNow: z.boolean().optional().default(false), + paid: z.boolean().optional().default(false), + premium: z.boolean().optional().default(false) +}); + +export type RecommendedVideo = z.infer; + +export const StreamModel = z.object({ + type: z.string(), + title: z.string(), + videoId: z.string(), + videoThumbnails: ThumbnailModel.array(), + storyboards: StoryboardModel.array(), + description: z.string(), + descriptionHtml: z.string(), + published: z.number(), + publishedText: z.string(), + keywords: z.string().array(), + viewCount: z.number(), + likeCount: z.number(), + dislikeCount: z.number(), + paid: z.boolean().optional().default(false), + premium: z.boolean().optional().default(false), + isFamilyFriendly: z.boolean(), + allowedRegions: z.string().array(), + genre: z.string(), + genreUrl: z.string(), + author: z.string(), + authorId: z.string(), + authorUrl: z.string(), + authorVerified: z.boolean(), + authorThumbnails: AuthorThumbnailModel.array(), + subCountText: z.string(), + lengthSeconds: z.number(), + allowRatings: z.boolean(), + rating: z.number(), + isListed: z.boolean(), + liveNow: z.boolean().optional().default(false), + isUpcoming: z.boolean(), + dashUrl: z.string().url(), + adaptiveFormats: AdaptiveFormatModel.array(), + formatStreams: FormatStreamModel.array(), + captions: CaptionModel.array(), + recommendedVideos: RecommendedVideoModel.array() +}); + +type Stream = z.infer; + +export default Stream; diff --git a/src/client/adapters/invidious/typings/thumbnail.ts b/src/client/adapters/invidious/typings/thumbnail.ts index 730a49d..57f7b30 100644 --- a/src/client/adapters/invidious/typings/thumbnail.ts +++ b/src/client/adapters/invidious/typings/thumbnail.ts @@ -12,11 +12,17 @@ const qualityTypes = [ "end" ] as const; +export const AuthorThumbnailModel = z.object({ + url: z.string(), + width: z.number(), + height: z.number() +}); + export const ThumbnailModel = z.object({ - quality: z.enum(qualityTypes), url: z.string().url(), width: z.number(), - height: z.number() + height: z.number(), + quality: z.enum(qualityTypes) }); type Thumbnail = z.infer; diff --git a/src/client/adapters/invidious/typings/video.ts b/src/client/adapters/invidious/typings/video.ts index 37f44c7..3b3dadd 100644 --- a/src/client/adapters/invidious/typings/video.ts +++ b/src/client/adapters/invidious/typings/video.ts @@ -14,13 +14,13 @@ export const VideoModel = z.object({ authorUrl: z.string(), published: z.number(), - publishedText: z.string(), + publishedText: z.string().optional(), description: z.string(), descriptionHtml: z.string(), - liveNow: z.boolean(), + liveNow: z.boolean().optional().default(false), paid: z.boolean().optional().default(false), - premium: z.boolean() + premium: z.boolean().optional().default(false) }); type Video = z.infer; diff --git a/src/client/adapters/piped/index.ts b/src/client/adapters/piped/index.ts index 48e4755..0995405 100644 --- a/src/client/adapters/piped/index.ts +++ b/src/client/adapters/piped/index.ts @@ -9,6 +9,7 @@ import Transformer from "./transformer"; import { Suggestions } from "@/client/typings/search/suggestions"; import Search, { SearchModel } from "./typings/search"; import path from "path"; +import Stream, { StreamModel } from "./typings/stream"; const getTrending = async ( apiBaseUrl: string, @@ -82,6 +83,21 @@ const getSearch = async ( return data; }; +const getStream = async ( + apiBaseUrl: string, + videoId: string +): Promise => { + const url = new URL(path.join("streams", videoId), apiBaseUrl); + + const response = await ky.get(url); + + const json = await response.json(); + + const data = StreamModel.parse(json); + + return data; +}; + const adapter: Adapter = { apiType: ApiType.Piped, @@ -119,6 +135,10 @@ const adapter: Adapter = { filter: filter, nextpage: options?.pageParam }).then(Transformer.search); + }, + + async getStream(videoId) { + return getStream(url, videoId).then(Transformer.stream); } }; } diff --git a/src/client/adapters/piped/transformer.ts b/src/client/adapters/piped/transformer.ts index f171d6a..712d482 100644 --- a/src/client/adapters/piped/transformer.ts +++ b/src/client/adapters/piped/transformer.ts @@ -1,19 +1,71 @@ import { Video } from "@/client/typings/video"; -import { - ChannelResult, - PlaylistResult, - SearchResults, - VideoResult -} from "@/client/typings/search"; +import { SearchResults } from "@/client/typings/search"; +import { Stream } from "@/client/typings/stream"; import PipedVideo from "./typings/video"; import PipedSearch from "./typings/search"; +import PipedStream from "./typings/stream"; +import PipedItem from "./typings/item"; import { parseChannelIdFromUrl, parseVideoIdFromUrl } from "@/utils/parseIdFromUrl"; +import { + ChannelItem, + Item, + PlaylistItem, + VideoItem +} from "@/client/typings/item"; export default class Transformer { + private static item(data: PipedItem): Item { + switch (data.type) { + case "stream": + const video: VideoItem = { + ...Transformer.video(data), + type: "video" + }; + + return video; + + case "channel": + const id = parseChannelIdFromUrl(data.url); + + if (id === null) throw new Error("Piped: Missing channelId"); + + const channel: ChannelItem = { + type: "channel", + name: data.name, + id: id, + thumbnail: data.thumbnail, + subscribers: data.subscribers, + videos: data.videos, + description: data.description ?? "" + }; + + return channel; + + case "playlist": + const channelId = parseChannelIdFromUrl(data.uploaderUrl); + + if (channelId === null) throw new Error("Piped: Missing channelId"); + + const playlist: PlaylistItem = { + type: "playlist", + title: data.name, + author: { + name: data.uploaderName, + id: channelId + }, + thumbnail: data.thumbnail, + id: data.url, + numberOfVideos: data.videos + }; + + return playlist; + } + } + public static video(data: PipedVideo): Video { const videoId = parseVideoIdFromUrl(data.url); @@ -45,54 +97,37 @@ export default class Transformer { } public static search(data: PipedSearch): SearchResults { - const items = 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 channel: ChannelResult = { - type: "channel", - name: result.name, - id: id, - thumbnail: result.thumbnail, - subscribers: result.subscribers, - videos: result.videos, - description: result.description ?? "" - }; - - return channel; - - case "playlist": - const channelId = parseChannelIdFromUrl(result.uploaderUrl); - - if (channelId === null) throw new Error("Piped: Missing channelId"); - - const playlist: PlaylistResult = { - type: "playlist", - title: result.name, - author: { - name: result.uploaderName, - id: channelId - }, - thumbnail: result.thumbnail, - id: result.url, - numberOfVideos: result.videos - }; - - return playlist; - } - }); + const items = data.items.map(Transformer.item); return { items, nextCursor: data.nextpage }; } + + public static stream(data: PipedStream): Stream { + const channelId = parseChannelIdFromUrl(data.uploaderUrl); + + if (channelId === null) throw new Error("Piped: Missing channelId"); + + return { + category: data.category, + keywords: data.tags, + dislikes: data.dislikes, + likes: data.likes, + related: data.relatedStreams.map(Transformer.item), + video: { + author: { + id: channelId, + name: data.uploader, + avatar: data.uploaderAvatar + }, + description: data.description, + duration: data.duration * 1000, + id: "", + live: data.livestream, + thumbnail: data.thumbnailUrl, + title: data.title, + uploaded: data.uploadDate, + views: data.views + } + }; + } } diff --git a/src/client/adapters/piped/typings/item.ts b/src/client/adapters/piped/typings/item.ts new file mode 100644 index 0000000..225e82e --- /dev/null +++ b/src/client/adapters/piped/typings/item.ts @@ -0,0 +1,41 @@ +import z from "zod"; +import { VideoModel } from "./video"; + +export const VideoItemModel = z + .object({ + type: z.literal("stream") + }) + .and(VideoModel); + +export const ChannelItemModel = z.object({ + type: z.literal("channel"), + url: z.string(), + name: z.string(), + thumbnail: z.string().url(), + description: z.string().nullable(), + subscribers: z.number(), + videos: z.number(), + verified: z.boolean() +}); + +export const PlaylistItemModel = 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 ItemModel = z.union([ + VideoItemModel, + ChannelItemModel, + PlaylistItemModel +]); + +type Item = z.infer; + +export default Item; diff --git a/src/client/adapters/piped/typings/search/index.ts b/src/client/adapters/piped/typings/search/index.ts index cbdee41..7b734d5 100644 --- a/src/client/adapters/piped/typings/search/index.ts +++ b/src/client/adapters/piped/typings/search/index.ts @@ -1,39 +1,9 @@ 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().nullable(), - 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() -}); +import { ItemModel } from "../item"; export const SearchModel = z.object({ - items: z - .union([VideoResultModel, ChannelResultModel, PlaylistResultModel]) - .array(), + items: ItemModel.array(), nextpage: z.string(), suggestion: z.string().nullable(), corrected: z.boolean() diff --git a/src/client/adapters/piped/typings/stream.ts b/src/client/adapters/piped/typings/stream.ts new file mode 100644 index 0000000..754b626 --- /dev/null +++ b/src/client/adapters/piped/typings/stream.ts @@ -0,0 +1,100 @@ +import z from "zod"; +import { ItemModel } from "./item"; + +export const AudioStreamModel = z.object({ + url: z.string().url(), + format: z.string(), + quality: z.string(), + mimeType: z.string(), + codec: z.string().nullable(), + audioTrackId: z.null(), + audioTrackName: z.null(), + audioTrackType: z.null(), + audioTrackLocale: z.null(), + videoOnly: z.boolean(), + itag: z.number(), + bitrate: z.number(), + initStart: z.number(), + initEnd: z.number(), + indexStart: z.number(), + indexEnd: z.number(), + width: z.number(), + height: z.number(), + fps: z.number(), + contentLength: z.number() +}); + +export const VideoStreamModel = z.object({ + url: z.string(), + format: z.string(), + quality: z.string(), + mimeType: z.string(), + codec: z.string().nullable(), + audioTrackId: z.null(), + audioTrackName: z.null(), + audioTrackType: z.null(), + audioTrackLocale: z.null(), + videoOnly: z.boolean(), + itag: z.number(), + bitrate: z.number(), + initStart: z.number(), + initEnd: z.number(), + indexStart: z.number(), + indexEnd: z.number(), + width: z.number(), + height: z.number(), + fps: z.number(), + contentLength: z.number() +}); + +export const ChapterModel = z.object({ + title: z.string(), + image: z.string(), + start: z.number() +}); + +export const PreviewFrameModel = z.object({ + urls: z.array(z.string()), + frameWidth: z.number(), + frameHeight: z.number(), + totalCount: z.number(), + durationPerFrame: z.number(), + framesPerPageX: z.number(), + framesPerPageY: z.number() +}); + +export const StreamModel = z.object({ + title: z.string(), + description: z.string(), + uploadDate: z.coerce.date(), + uploader: z.string(), + uploaderUrl: z.string(), + uploaderAvatar: z.string().url(), + thumbnailUrl: z.string().url(), + hls: z.string().url(), + dash: z.null(), + lbryId: z.null(), + category: z.string(), + license: z.string(), + visibility: z.string(), + tags: z.array(z.string()), + metaInfo: z.array(z.unknown()), + uploaderVerified: z.boolean(), + duration: z.number(), + views: z.number(), + likes: z.number(), + dislikes: z.number(), + uploaderSubscriberCount: z.number(), + audioStreams: AudioStreamModel.array(), + videoStreams: VideoStreamModel.array(), + relatedStreams: ItemModel.array(), + subtitles: z.array(z.unknown()), + livestream: z.boolean(), + proxyUrl: z.string().url(), + chapters: ChapterModel.array(), + previewFrames: PreviewFrameModel.array() +}); + +type Stream = z.infer; + +export default Stream; diff --git a/src/client/index.ts b/src/client/index.ts index 0199339..adb72d1 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -7,6 +7,7 @@ import Adapter, { ApiType, ConnectedAdapter } from "./adapters"; import { Suggestions } from "./typings/search/suggestions"; import { SearchResults } from "./typings/search"; import { SearchOptions } from "./typings/search/options"; +import { Stream } from "./typings/stream"; export interface RemoteApi { type: ApiType; @@ -76,4 +77,10 @@ export default class Client { type: options?.type ?? "all" }); } + + public async getStream(videoId: string): Promise { + const adapter = this.getBestAdapter(); + + return await adapter.getStream(videoId); + } } diff --git a/src/client/typings/item.ts b/src/client/typings/item.ts new file mode 100644 index 0000000..fb31d13 --- /dev/null +++ b/src/client/typings/item.ts @@ -0,0 +1,33 @@ +import { Video } from "./video"; + +export type VideoItem = Video & { type: "video" }; + +export interface ChannelItem { + type: "channel"; + name: string; + id: string; + thumbnail: string; + subscribers: number; + videos: number; + description: string; +} + +export interface PlaylistItem { + type: "playlist"; + title: string; + id: string; + author: { + name: string; + id: string; + }; + numberOfVideos: number; + thumbnail: string; + videos?: { + title: string; + id: string; + duration: number; + thumbnail: string; + }[]; +} + +export type Item = VideoItem | ChannelItem | PlaylistItem; diff --git a/src/client/typings/search/index.ts b/src/client/typings/search/index.ts index 1c4f3cb..f04a5f3 100644 --- a/src/client/typings/search/index.ts +++ b/src/client/typings/search/index.ts @@ -1,38 +1,6 @@ -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; - thumbnail: string; - videos?: { - title: string; - id: string; - duration: number; - thumbnail: string; - }[]; -} - -export type SearchItems = (VideoResult | ChannelResult | PlaylistResult)[]; +import { Item } from "../item"; export interface SearchResults { - items: SearchItems; + items: Item[]; nextCursor: string; } diff --git a/src/client/typings/stream.ts b/src/client/typings/stream.ts new file mode 100644 index 0000000..c174456 --- /dev/null +++ b/src/client/typings/stream.ts @@ -0,0 +1,11 @@ +import { Item } from "./item"; +import { Video } from "./video"; + +export interface Stream { + video: Video; + keywords: string[]; + likes: number; + dislikes: number; + category: string; + related: Item[]; +} diff --git a/src/components/Video.tsx b/src/components/Video.tsx index d069f25..dbce789 100644 --- a/src/components/Video.tsx +++ b/src/components/Video.tsx @@ -13,10 +13,10 @@ import { ContextMenuItem } from "@/typings/contextMenu"; import NextImage from "next/image"; import { videoSize } from "@/utils/videoSize"; +import { channelUrl, videoUrl } from "@/utils/urls"; export const Video: Component<{ data: VideoProps }> = ({ data }) => { - const url = `/watch?v=${data.id}`; - const channelUrl = `/channel/${data.author.id}`; + const url = videoUrl(data.id); const [width, height] = videoSize([16, 9], 40); @@ -43,7 +43,11 @@ export const Video: Component<{ data: VideoProps }> = ({ data }) => { }, showDivider: true }, - { title: "Go to channel", key: "gotoChannel", href: channelUrl }, + { + title: "Go to channel", + key: "gotoChannel", + href: channelUrl(data.author.id) + }, { title: "Copy channel id", key: "channelId", diff --git a/src/hooks/useClient.ts b/src/hooks/useClient.ts index d58e913..3b7abfc 100644 --- a/src/hooks/useClient.ts +++ b/src/hooks/useClient.ts @@ -7,7 +7,7 @@ export const useClient = () => { const [client] = useState( () => new Client([ - // { baseUrl: "https://invidious.drgns.space", type: ApiType.Invidious } + // { baseUrl: "https://invidious.fdn.fr/", type: ApiType.Invidious } { baseUrl: "https://pipedapi.kavin.rocks", type: ApiType.Piped } ]) ); diff --git a/src/utils/urls.ts b/src/utils/urls.ts new file mode 100644 index 0000000..6c43eb2 --- /dev/null +++ b/src/utils/urls.ts @@ -0,0 +1,4 @@ +export const videoUrl = (videoId: string): string => `/watch?v=${videoId}`; + +export const channelUrl = (channelId: string): string => + `/channel/${channelId}`;