watch: started work on stream backend
continuous-integration/drone/push Build is failing Details

nextui
Guus van Meerveld 8 months ago
parent 1ffb926631
commit f24c17d35e

@ -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
}

@ -0,0 +1,11 @@
meta {
name: Stream
type: http
seq: 2
}
get {
url: https://pipedapi.kavin.rocks/streams/CcHevgjAnV0
body: none
auth: none
}

@ -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 (
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />

@ -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;

@ -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}`;

@ -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}`;

@ -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 <></>;
};

@ -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<Video[]>;
getSearchSuggestions(query: string): Promise<Suggestions>;
getSearch(query: string, options?: SearchOptions): Promise<SearchResults>;
getStream(videoId: string): Promise<Stream>;
}
export default interface Adapter {

@ -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<Stream> => {
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);
}
};
}

@ -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
}
};
}
}

@ -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(),

@ -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<typeof StoryboardModel>;

@ -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<typeof RecommendedVideoModel>;
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<typeof StreamModel>;
export default Stream;

@ -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<typeof ThumbnailModel>;

@ -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<typeof VideoModel>;

@ -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<Stream> => {
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);
}
};
}

@ -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
}
};
}
}

@ -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<typeof ItemModel>;
export default Item;

@ -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()

@ -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<typeof StreamModel>;
export default Stream;

@ -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<Stream> {
const adapter = this.getBestAdapter();
return await adapter.getStream(videoId);
}
}

@ -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;

@ -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;
}

@ -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[];
}

@ -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",

@ -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 }
])
);

@ -0,0 +1,4 @@
export const videoUrl = (videoId: string): string => `/watch?v=${videoId}`;
export const channelUrl = (channelId: string): string =>
`/channel/${channelId}`;
Loading…
Cancel
Save