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 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 {
|
export default class Transformer {
|
||||||
public static trending(data: InvidiousTrending[]): VideoPreview[] {
|
private static findBestThumbnail(
|
||||||
return data.map((video) => {
|
thumbnails: InvidiousThumbnail[]
|
||||||
const thumbnail = video.videoThumbnails.find(
|
): string | null {
|
||||||
(thumbnail) =>
|
const thumbnail = thumbnails.find(
|
||||||
thumbnail.quality == "default" ||
|
(thumbnail) =>
|
||||||
thumbnail.quality == "medium" ||
|
thumbnail.quality == "default" ||
|
||||||
thumbnail.quality == "middle"
|
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)
|
return {
|
||||||
throw new Error(
|
author: { id: data.authorId, name: data.author },
|
||||||
`Invidious: Missing thumbnail for video with id ${video.videoId}`
|
duration: data.lengthSeconds * 1000,
|
||||||
);
|
description: data.description,
|
||||||
|
live: data.liveNow,
|
||||||
return {
|
id: data.videoId,
|
||||||
author: { id: video.authorId, name: video.author },
|
title: data.title,
|
||||||
duration: video.lengthSeconds * 1000,
|
thumbnail: thumbnail,
|
||||||
id: video.videoId,
|
uploaded: new Date(data.published * 1000 ?? 0),
|
||||||
title: video.title,
|
views: data.viewCount
|
||||||
thumbnail: thumbnail.url,
|
};
|
||||||
uploaded: new Date(video.published * 1000 ?? 0),
|
}
|
||||||
views: video.viewCount
|
|
||||||
};
|
public static videos(data: InvidiousVideo[]): Video[] {
|
||||||
});
|
return data.map(Transformer.video);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static suggestions(data: InvidiousSuggestions): Suggestions {
|
public static suggestions(data: InvidiousSuggestions): Suggestions {
|
||||||
return data.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";
|
import PipedVideo from "./typings/video";
|
||||||
|
import PipedSearch from "./typings/search";
|
||||||
const videoIdRegex = /\/watch\?v=(.+)/;
|
import {
|
||||||
const channelIdRegex = /\/channel\/(.+)/;
|
parseChannelIdFromUrl,
|
||||||
|
parseVideoIdFromUrl
|
||||||
|
} from "@/utils/parseIdFromUrl";
|
||||||
|
|
||||||
export default class Transformer {
|
export default class Transformer {
|
||||||
public static trending(data: PipedTrending[]): VideoPreview[] {
|
public static video(data: PipedVideo): Video {
|
||||||
return data.map((video) => {
|
const videoId = parseVideoIdFromUrl(data.url);
|
||||||
const videoIdMatch = video.url.match(videoIdRegex);
|
|
||||||
|
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)
|
const playlist: PlaylistResult = {
|
||||||
throw new Error("Piped: Missing trending channelId");
|
type: "playlist",
|
||||||
|
title: result.name,
|
||||||
|
author: {
|
||||||
|
name: result.uploaderName,
|
||||||
|
id: channelId
|
||||||
|
},
|
||||||
|
id: result.url,
|
||||||
|
numberOfVideos: result.videos
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return playlist;
|
||||||
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 }
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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;
|
title: string;
|
||||||
thumbnail: string;
|
|
||||||
id: string;
|
id: string;
|
||||||
author: {
|
author: {
|
||||||
name: string;
|
name: string;
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
thumbnail: string;
|
||||||
|
description: string;
|
||||||
/*
|
/*
|
||||||
Duration in milliseconds.
|
Duration in milliseconds.
|
||||||
*/
|
*/
|
||||||
duration: number;
|
duration: number;
|
||||||
views: number;
|
views: number;
|
||||||
uploaded: Date;
|
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