basic search backend implemented
continuous-integration/drone/push Build is passing
Details
continuous-integration/drone/push Build is passing
Details
parent
ed53ae1ea1
commit
c7dd2ddd12
@ -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,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;
|
@ -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;
|
||||
}
|
@ -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…
Reference in new issue