add channel data

This commit is contained in:
murdle 2025-12-22 10:01:19 +02:00
parent 3355a51b74
commit cc3309d062
9 changed files with 126 additions and 8 deletions

65
src/api/channel.ts Normal file
View File

@ -0,0 +1,65 @@
import { zValidator } from "@hono/zod-validator";
import innertube from "../lib/innertube.js";
import { ChannelFindSchema } from "../models/ChannelFindSchema.js";
import { Hono } from "hono";
import channelData from "../templates/channelData.js";
import { parseSubscriberCount } from "../lib/channel.js";
import { YTNodes } from "youtubei.js";
const channel = new Hono();
channel.get(
"/channels/:id",
zValidator("param", ChannelFindSchema),
async (c) => {
const { id } = c.req.valid("param");
try {
const data = await innertube.getChannel(id);
const { header } = data;
if (!header) {
return c.text("Channel not found", 404);
}
const channelInfo = header.is(YTNodes.PageHeader)
? {
name: header.page_title,
description: header.content?.description?.description?.text || "",
thumbnail: (header.content?.image as YTNodes.DecoratedAvatarView)
?.avatar?.image?.[0]?.url || "",
subCount: parseSubscriberCount(
header.content?.metadata?.metadata_rows?.[1]
?.metadata_parts?.[0]?.text?.text || "0"
),
}
: header.is(YTNodes.C4TabbedHeader)
? {
name: header.author?.name || "",
description: data.metadata?.description || "",
thumbnail: header.author?.best_thumbnail?.url || "",
subCount: parseSubscriberCount(header.subscribers?.text || "0"),
}
: null;
if (!channelInfo) {
return c.text("Channel not found", 404);
}
return c.html(
channelData(
id,
channelInfo.name,
channelInfo.subCount,
channelInfo.description,
channelInfo.thumbnail
)
);
} catch (err) {
console.error("Error fetching channel:", err);
return c.text("Channel not found", 404);
}
}
);
export default channel

View File

@ -1,13 +1,13 @@
import { zValidator } from "@hono/zod-validator";
import innertube from "../lib/innertube.js";
import { PlayVideo } from "../models/PlayVideo.js";
import { VideoFindSchema } from "../models/VideoFindSchema.js";
import { Hono } from "hono";
const playback = new Hono();
playback.get(
"/:id",
zValidator("param", PlayVideo),
zValidator("param", VideoFindSchema),
async (c) => {
const { id } = c.req.valid("param");
const data = await innertube.getStreamingData(id, {

View File

@ -1,7 +1,7 @@
import { Hono } from "hono";
import innertube from "../lib/innertube.js";
import { zValidator } from "@hono/zod-validator";
import { SearchVideos } from "../models/SearchVideos.js";
import { VideoSearchSchema } from "../models/VideoSearchSchema.js";
import search from "../templates/search.js";
import { YTNodes } from "youtubei.js";
@ -9,7 +9,7 @@ const video = new Hono();
video.get(
"/videos",
zValidator("query", SearchVideos),
zValidator("query", VideoSearchSchema),
async (c) => {
const params = c.req.valid("query");
const page = params["start-index"] ?? 1;

View File

@ -7,6 +7,7 @@ import { config } from "./lib/config.js"
import video from "./api/video.js"
import playback from "./api/playback.js"
import channel from "./api/channel.js"
const app = new Hono()
@ -15,6 +16,7 @@ app.use("/schemas/*", serveStatic({ root: "./" }))
app.route("/getvideo", playback);
app.route("/feeds/api", video);
app.route("/feeds/api", channel);
serve({
fetch: app.fetch,

16
src/lib/channel.ts Normal file
View File

@ -0,0 +1,16 @@
export function parseSubscriberCount(text: string) {
const cleaned = text.replace(/[^\d.KMB]/gi, "");
const multipliers: Record<string, number> = {
K: 1_000,
M: 1_000_000,
B: 1_000_000_000,
};
for (const [suffix, multiplier] of Object.entries(multipliers)) {
if (cleaned.toUpperCase().includes(suffix)) {
return Math.floor(parseFloat(cleaned) * multiplier);
}
}
return parseInt(cleaned) || 0;
}

View File

@ -0,0 +1,5 @@
import z from "zod";
export const ChannelFindSchema = z.object({
id: z.string().length(24)
});

View File

@ -1,5 +1,5 @@
import z from "zod";
export const PlayVideo = z.object({
export const VideoFindSchema = z.object({
id: z.string().length(11)
});

View File

@ -1,6 +1,6 @@
import z from "zod";
export const SearchVideos = z.object({
export const VideoSearchSchema = z.object({
q: z.string().min(1),
time: z.preprocess((val) => {
if (typeof val !== "string") return undefined;
@ -17,9 +17,10 @@ export const SearchVideos = z.object({
if (typeof val !== "string") return undefined;
switch (val) {
case "rating": return "rating";
case "published": "upload_date;"
case "published": return "upload_date;"
case "viewCount": return "view_count";
default: return "relevance";
case "relevance": return "relevance";
default: return undefined;
}
}, z.enum(["relevance", "rating", "upload_date", "view_count"]).optional()),
duration: z.enum(['all', 'short', 'medium', 'long']).optional(),

View File

@ -0,0 +1,29 @@
import { html } from "hono/html";
export default (
id: string,
name: string,
subCount: number,
description: string,
pictureUrl: string
) => html`
<entry gd:etag='W/&quot;Ck8GRH47eCp7I2A9XRdTGEQ.&quot;'>
<id>tag:youtube.com,2008:channel:${id}</id>
<updated>2014-09-16T18:07:05.000Z</updated>
<category scheme='http://schemas.google.com/g/2005#kind' term='/schemas/2007#channel'/>
<title>${name}</title>
<summary>${description}</summary>
<link rel='/schemas/2007#featured-video' type='application/atom+xml' href='/feeds/api/videos/YM582qGZHLI?v=2'/>
<link rel='alternate' type='text/html' href='https://www.youtube.com/channel/${id}'/>
<link rel='self' type='application/atom+xml' href='/feeds/api/channels/${id}?v=2'/>
<author>
<name>${name}</name>
<uri>/feeds/api/users/webauditors</uri>
<yt:userId>${id}</yt:userId>
</author>
<yt:channelId>${id}</yt:channelId>
<yt:channelStatistics subscriberCount='${subCount}' viewCount='0'/>
<gd:feedLink rel='/schemas/2007#channel.content' href='/feeds/api/users/webauditors/uploads?v=2' countHint='0'/>
<media:thumbnail url='${pictureUrl}'/>
</entry>
`