add feed
This commit is contained in:
parent
cc3309d062
commit
c82b1ac6b0
3
.gitignore
vendored
3
.gitignore
vendored
@ -26,3 +26,6 @@ lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# cache
|
||||
cache/
|
||||
@ -4,5 +4,5 @@ npm run dev
|
||||
```
|
||||
|
||||
```
|
||||
open http://localhost:3000
|
||||
open http://localhost:4000
|
||||
```
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
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";
|
||||
import { ChannelFindSchema } from "../models/ChannelFindSchema.js";
|
||||
import { parseChannel } from "../lib/utils/channel.js";
|
||||
import { Hono } from "hono";
|
||||
|
||||
const channel = new Hono();
|
||||
|
||||
@ -16,45 +15,13 @@ channel.get(
|
||||
|
||||
try {
|
||||
const data = await innertube.getChannel(id);
|
||||
const { header } = data;
|
||||
const info = parseChannel(data);
|
||||
|
||||
if (!header) {
|
||||
if (!info) {
|
||||
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
|
||||
)
|
||||
);
|
||||
return c.html(channelData(info));
|
||||
} catch (err) {
|
||||
console.error("Error fetching channel:", err);
|
||||
return c.text("Channel not found", 404);
|
||||
|
||||
25
src/api/feed.ts
Normal file
25
src/api/feed.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import innertube from "../lib/innertube.js";
|
||||
import homeFeed from "../templates/homeFeed.js";
|
||||
import { FeedGetSchema } from "../models/FeedGetSchema.js";
|
||||
import { parseHomeFeed } from "../lib/utils/feed.js";
|
||||
import type { YT } from "youtubei.js";
|
||||
|
||||
const feed = new Hono();
|
||||
|
||||
feed.get(
|
||||
"/standardfeeds/:region/:type",
|
||||
zValidator("param", FeedGetSchema),
|
||||
async (c) => {
|
||||
const { type } = c.req.valid("param");
|
||||
if (type != "most_popular") return c.html(homeFeed())
|
||||
|
||||
const data = await innertube.getHomeFeed();
|
||||
const feed = data as YT.HomeFeed;
|
||||
|
||||
return c.html(homeFeed(await parseHomeFeed(feed)))
|
||||
}
|
||||
)
|
||||
|
||||
export default feed
|
||||
@ -3,6 +3,7 @@ import innertube from "../lib/innertube.js";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { VideoSearchSchema } from "../models/VideoSearchSchema.js";
|
||||
import search from "../templates/search.js";
|
||||
import { parseVideos } from "../lib/utils/video.js";
|
||||
import { YTNodes } from "youtubei.js";
|
||||
|
||||
const video = new Hono();
|
||||
@ -12,43 +13,47 @@ video.get(
|
||||
zValidator("query", VideoSearchSchema),
|
||||
async (c) => {
|
||||
const params = c.req.valid("query");
|
||||
const page = params["start-index"] ?? 1;
|
||||
const page = Number(params["start-index"] ?? 1);
|
||||
|
||||
if (page > 5) {
|
||||
return c.html(search([], ""));
|
||||
}
|
||||
|
||||
// * fetch all videos up to current page
|
||||
const allVideos: YTNodes.Video[] = [];
|
||||
|
||||
let searchQuery = await innertube.search(params.q, {
|
||||
type: "video",
|
||||
duration: params.duration,
|
||||
upload_date: params.time,
|
||||
sort_by: params.orderby
|
||||
});
|
||||
|
||||
for (let i = 1; i <= page; i++) {
|
||||
let currentPage = 1;
|
||||
|
||||
while (currentPage <= page) {
|
||||
const videos = searchQuery.results.filter(
|
||||
(node): node is YTNodes.Video => node.type === "Video"
|
||||
);
|
||||
|
||||
allVideos.push(...videos);
|
||||
|
||||
if (i < page) {
|
||||
try {
|
||||
const nextPage = await searchQuery.getContinuation();
|
||||
if (!nextPage) break;
|
||||
searchQuery = nextPage;
|
||||
} catch (err) {
|
||||
// * no more pages available
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!searchQuery.has_continuation) break;
|
||||
if (currentPage === page) break;
|
||||
|
||||
searchQuery = await searchQuery.getContinuation();
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
// * build next page URL
|
||||
const nextUrl = new URL(c.req.url);
|
||||
nextUrl.searchParams.set("start-index", (page + 1).toString());
|
||||
nextUrl.searchParams.set("start-index", String(page + 1));
|
||||
|
||||
return c.html(search(allVideos, nextUrl.toString()));
|
||||
return c.html(
|
||||
search(
|
||||
parseVideos(allVideos),
|
||||
searchQuery.has_continuation ? nextUrl.toString() : ""
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -8,6 +8,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"
|
||||
import feed from "./api/feed.js"
|
||||
|
||||
const app = new Hono()
|
||||
|
||||
@ -17,6 +18,7 @@ app.use("/schemas/*", serveStatic({ root: "./" }))
|
||||
app.route("/getvideo", playback);
|
||||
app.route("/feeds/api", video);
|
||||
app.route("/feeds/api", channel);
|
||||
app.route("/feeds/api", feed);
|
||||
|
||||
serve({
|
||||
fetch: app.fetch,
|
||||
|
||||
@ -1,16 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@ -1,12 +1,25 @@
|
||||
import { z } from "zod";
|
||||
import "dotenv/config"
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
const cookiesPath = path.resolve(process.cwd(), "cookies.txt");
|
||||
|
||||
let cookies: string | null = null;
|
||||
|
||||
if (fs.existsSync(cookiesPath)) {
|
||||
cookies = fs.readFileSync(cookiesPath, "utf8").trim();
|
||||
}
|
||||
|
||||
const EnvSchema = z.object({
|
||||
BASE_URL: z.url(),
|
||||
PORT: z.coerce.number().default(4000)
|
||||
BASE_URL: z.string().url(),
|
||||
PORT: z.coerce.number().default(4000),
|
||||
COOKIES: z.string().nullable().default(null),
|
||||
});
|
||||
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
const parsed = EnvSchema.safeParse({
|
||||
...process.env,
|
||||
COOKIES: cookies ?? null,
|
||||
});
|
||||
|
||||
if (!parsed.success) {
|
||||
console.error("Invalid config", parsed.error.format());
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import { Innertube, Platform, type Types } from 'youtubei.js';
|
||||
import { ClientType, Innertube, Platform, type Types, UniversalCache } from 'youtubei.js';
|
||||
import { config } from './config.js';
|
||||
|
||||
Platform.shim.eval = async (
|
||||
data: Types.BuildScriptResult,
|
||||
@ -12,5 +13,11 @@ Platform.shim.eval = async (
|
||||
return new Function(code)();
|
||||
};
|
||||
|
||||
const innertube = await Innertube.create();
|
||||
const innertube = await Innertube.create({
|
||||
cache: new UniversalCache(true, "./cache"),
|
||||
device_category: "mobile",
|
||||
enable_session_cache: true,
|
||||
client_type: ClientType.WEB,
|
||||
...(config.COOKIES ? { cookie: config.COOKIES } : {})
|
||||
});
|
||||
export default innertube
|
||||
66
src/lib/utils.ts
Normal file
66
src/lib/utils.ts
Normal file
@ -0,0 +1,66 @@
|
||||
export function parseCount(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;
|
||||
}
|
||||
|
||||
export function parseDuration(duration: string) {
|
||||
const parts = duration.split(":").map(Number);
|
||||
|
||||
if (parts.length === 3) {
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
return parts[0] * 60 + parts[1];
|
||||
} else if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function parseRelativeDate(text: string): string | null {
|
||||
const now = new Date();
|
||||
const match = text.match(/(\d+)\s+(second|minute|hour|day|week|month|year)s?\s+ago/);
|
||||
if (!match) return null;
|
||||
|
||||
const unit = match[2];
|
||||
const date = new Date(now);
|
||||
const value = parseInt(match[1]);
|
||||
|
||||
switch (unit) {
|
||||
case "second":
|
||||
date.setSeconds(date.getSeconds() - value);
|
||||
break;
|
||||
case "minute":
|
||||
date.setMinutes(date.getMinutes() - value);
|
||||
break;
|
||||
case "hour":
|
||||
date.setHours(date.getHours() - value);
|
||||
break;
|
||||
case "day":
|
||||
date.setDate(date.getDate() - value);
|
||||
break;
|
||||
case "week":
|
||||
date.setDate(date.getDate() - value * 7);
|
||||
break;
|
||||
case "month":
|
||||
date.setMonth(date.getMonth() - value);
|
||||
break;
|
||||
case "year":
|
||||
date.setFullYear(date.getFullYear() - value);
|
||||
break;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
42
src/lib/utils/channel.ts
Normal file
42
src/lib/utils/channel.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { YTNodes, YT } from "youtubei.js";
|
||||
import { parseCount } from "../utils.js";
|
||||
|
||||
export interface Channel {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
thumbnail: string;
|
||||
subCount: number;
|
||||
}
|
||||
|
||||
// TODO: make this not ugly
|
||||
export function parseChannel(data: YT.Channel): Channel | null {
|
||||
const { header, metadata } = data;
|
||||
if (!header) return null;
|
||||
|
||||
if (header.is(YTNodes.PageHeader)) {
|
||||
return {
|
||||
id: metadata?.external_id || "",
|
||||
name: header.page_title,
|
||||
description: header.content?.description?.description?.text || "",
|
||||
thumbnail: (header.content?.image as YTNodes.DecoratedAvatarView)
|
||||
?.avatar?.image?.[0]?.url || "",
|
||||
subCount: parseCount(
|
||||
header.content?.metadata?.metadata_rows?.[1]
|
||||
?.metadata_parts?.[0]?.text?.text || "0"
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (header.is(YTNodes.C4TabbedHeader)) {
|
||||
return {
|
||||
id: metadata?.external_id || "",
|
||||
name: header.author?.name || "",
|
||||
description: data.metadata?.description || "",
|
||||
thumbnail: header.author?.best_thumbnail?.url || "",
|
||||
subCount: parseCount(header.subscribers?.text || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
74
src/lib/utils/feed.ts
Normal file
74
src/lib/utils/feed.ts
Normal file
@ -0,0 +1,74 @@
|
||||
import { YTNodes } from "youtubei.js";
|
||||
import { parseCount, parseDuration, parseRelativeDate } from "../utils.js";
|
||||
import { type Video } from "./video.js";
|
||||
import { YT } from "youtubei.js";
|
||||
|
||||
export function parseFeedVideo(
|
||||
video: YTNodes.Video | YTNodes.RichItem
|
||||
): Video | null {
|
||||
const content = video.is(YTNodes.RichItem) ? video.content : video;
|
||||
if (!content || content.type !== "LockupView") return null;
|
||||
|
||||
const lockup = content as YTNodes.LockupView;
|
||||
if (lockup.content_type !== "VIDEO") return null;
|
||||
|
||||
const thumbnail = lockup.content_image as YTNodes.ThumbnailView;
|
||||
const metadataRows = lockup.metadata?.metadata?.metadata_rows ?? [];
|
||||
|
||||
// * extract author info
|
||||
const authorPart = metadataRows[0]?.metadata_parts?.[0]?.text;
|
||||
const authorName = authorPart?.text ?? "";
|
||||
const authorId = authorPart?.endpoint?.payload?.browseId ?? "";
|
||||
|
||||
// * extract view count and published date
|
||||
const viewText = metadataRows[1]?.metadata_parts?.[0]?.text?.text ?? "0";
|
||||
const createdText = metadataRows[1]?.metadata_parts?.[1]?.text?.text ?? "0 seconds ago";
|
||||
|
||||
// * extract video duration
|
||||
const overlay = thumbnail?.overlays[0] as YTNodes.ThumbnailOverlayBadgeView;
|
||||
const durationText = overlay ? overlay.badges[0]?.text : undefined;
|
||||
|
||||
return {
|
||||
id: lockup.content_id ?? "",
|
||||
title: lockup.metadata?.title?.text ?? "",
|
||||
description: "",
|
||||
thumbnailUrl: thumbnail?.image?.[0]?.url ?? "",
|
||||
authorName,
|
||||
authorId,
|
||||
viewCount: parseCount(viewText),
|
||||
created: parseRelativeDate(createdText) ?? "",
|
||||
duration: parseDuration(durationText ?? "0:00"),
|
||||
};
|
||||
}
|
||||
|
||||
export async function parseHomeFeed(feed: YT.HomeFeed): Promise<Video[]> {
|
||||
const videos: Video[] = [];
|
||||
let currentFeed = feed;
|
||||
|
||||
while (currentFeed.has_continuation) {
|
||||
const grid = currentFeed.contents;
|
||||
if (!grid || !grid.is(YTNodes.RichGrid)) break;
|
||||
|
||||
for (const item of grid.contents) {
|
||||
if (item.is(YTNodes.RichItem)) {
|
||||
const parsed = parseFeedVideo(item);
|
||||
if (parsed) videos.push(parsed);
|
||||
} else if (item.is(YTNodes.RichSection)) {
|
||||
const sectionContent = item.content;
|
||||
if (sectionContent?.is(YTNodes.RichShelf)) {
|
||||
for (const shelfItem of sectionContent.contents || []) {
|
||||
if (shelfItem.is(YTNodes.RichItem)) {
|
||||
const parsed = parseFeedVideo(shelfItem);
|
||||
if (parsed) videos.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const continuation = await currentFeed.getContinuation();
|
||||
currentFeed = continuation;
|
||||
}
|
||||
|
||||
return videos;
|
||||
}
|
||||
40
src/lib/utils/video.ts
Normal file
40
src/lib/utils/video.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import { YTNodes } from "youtubei.js";
|
||||
import { parseCount, parseDuration, parseRelativeDate } from "../utils.js";
|
||||
|
||||
export interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
authorId: string;
|
||||
authorName: string;
|
||||
duration: number;
|
||||
viewCount: number;
|
||||
thumbnailUrl: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export function parseVideos(videos: YTNodes.Video[]): Video[] {
|
||||
return videos.map(video => parseVideo(video));
|
||||
}
|
||||
|
||||
export function parseVideo(video: YTNodes.Video): Video {
|
||||
const authorId = video.author?.id && video.author.id !== "N/A"
|
||||
? video.author.id
|
||||
: video.video_id;
|
||||
|
||||
const authorName = video.author?.name && video.author.name !== "N/A"
|
||||
? video.author.name
|
||||
: "Unknown";
|
||||
|
||||
return {
|
||||
id: video.video_id,
|
||||
title: video.title?.text || "",
|
||||
description: video.description || "",
|
||||
authorId,
|
||||
authorName,
|
||||
duration: parseDuration(video.duration?.text || "0:00"),
|
||||
viewCount: parseCount(video.view_count?.text || "0"),
|
||||
thumbnailUrl: video.thumbnails?.[0]?.url || `https://i.ytimg.com/vi/${video.video_id}/default.jpg`,
|
||||
created: parseRelativeDate(video?.published?.text || "0 seconds ago") ?? ""
|
||||
};
|
||||
}
|
||||
6
src/models/FeedGetSchema.ts
Normal file
6
src/models/FeedGetSchema.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import z from "zod";
|
||||
|
||||
export const FeedGetSchema = z.object({
|
||||
region: z.string().length(2),
|
||||
type: z.enum(["most_popular", "most_popular_Film", "most_popular_Games", "most_popular_Music"])
|
||||
});
|
||||
@ -1,29 +1,26 @@
|
||||
import { html } from "hono/html";
|
||||
import type { Channel } from "../lib/utils/channel.js";
|
||||
|
||||
export default (
|
||||
id: string,
|
||||
name: string,
|
||||
subCount: number,
|
||||
description: string,
|
||||
pictureUrl: string
|
||||
channel: Channel
|
||||
) => html`
|
||||
<entry gd:etag='W/"Ck8GRH47eCp7I2A9XRdTGEQ."'>
|
||||
<id>tag:youtube.com,2008:channel:${id}</id>
|
||||
<id>tag:youtube.com,2008:channel:${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>
|
||||
<title>${channel.name}</title>
|
||||
<summary>${channel.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'/>
|
||||
<link rel='alternate' type='text/html' href='https://www.youtube.com/channel/${channel.id}'/>
|
||||
<link rel='self' type='application/atom+xml' href='/feeds/api/channels/${channel.id}?v=2'/>
|
||||
<author>
|
||||
<name>${name}</name>
|
||||
<name>${channel.name}</name>
|
||||
<uri>/feeds/api/users/webauditors</uri>
|
||||
<yt:userId>${id}</yt:userId>
|
||||
<yt:userId>${channel.id}</yt:userId>
|
||||
</author>
|
||||
<yt:channelId>${id}</yt:channelId>
|
||||
<yt:channelStatistics subscriberCount='${subCount}' viewCount='0'/>
|
||||
<yt:channelId>${channel.id}</yt:channelId>
|
||||
<yt:channelStatistics subscriberCount='${channel.subCount}' viewCount='0'/>
|
||||
<gd:feedLink rel='/schemas/2007#channel.content' href='/feeds/api/users/webauditors/uploads?v=2' countHint='0'/>
|
||||
<media:thumbnail url='${pictureUrl}'/>
|
||||
<media:thumbnail url='${channel.thumbnail}'/>
|
||||
</entry>
|
||||
`
|
||||
106
src/templates/homeFeed.ts
Normal file
106
src/templates/homeFeed.ts
Normal file
@ -0,0 +1,106 @@
|
||||
import { html } from "hono/html";
|
||||
import { config } from "../lib/config.js";
|
||||
import type { Video } from "../lib/utils/video.js";
|
||||
|
||||
export default (
|
||||
videos: Video[] = []
|
||||
) => html `
|
||||
<?xml version='1.0' encoding='UTF-8'?>
|
||||
<feed
|
||||
xmlns='http://www.w3.org/2005/Atom'
|
||||
xmlns:media='http://search.yahoo.com/mrss/'
|
||||
xmlns:openSearch='http://a9.com/-/spec/opensearch/1.1/'
|
||||
xmlns:gd='http://schemas.google.com/g/2005'
|
||||
xmlns:yt='${config.BASE_URL}/schemas/1970' gd:etag='W/"D0UHSX47eCp7I2A9WhJWF08."'>
|
||||
<id>tag:youtube.com,2008:playlist:8E2186857EE27746</id>
|
||||
<updated>2012-08-23T12:33:58.000Z</updated>
|
||||
<category scheme='http://schemas.google.com/g/2005#kind' term='${config.BASE_URL}/schemas/1970#playlist'/>
|
||||
<title></title>
|
||||
<subtitle></subtitle>
|
||||
<logo>http://www.gstatic.com/youtube/img/logo.png</logo>
|
||||
<link rel='alternate' type='text/html' href='http://www.youtube.com/playlist?list=PL8E2186857EE27746'/>
|
||||
<link rel='http://schemas.google.com/g/2005#feed' type='application/atom+xml' href='${config.BASE_URL}/feeds/api/playlists/8E2186857EE27746?v=2'/>
|
||||
<link rel='http://schemas.google.com/g/2005#batch' type='application/atom+xml' href='${config.BASE_URL}/feeds/api/playlists/8E2186857EE27746/batch?v=2'/>
|
||||
<link rel='self' type='application/atom+xml' href='${config.BASE_URL}/feeds/api/playlists/8E2186857EE27746?start-index=1&max-results=25&v=2'/>
|
||||
<link rel='service' type='application/atomsvc+xml' href='${config.BASE_URL}/feeds/api/playlists/8E2186857EE27746?alt=atom-service&v=2'/>
|
||||
<author>
|
||||
<name></name>
|
||||
<uri></uri>
|
||||
<yt:userId></yt:userId>
|
||||
</author>
|
||||
<generator version='2.1' uri='${config.BASE_URL}/'>YouTube data API</generator>
|
||||
<openSearch:totalResults>50</openSearch:totalResults>
|
||||
<openSearch:itemsPerPage>50</openSearch:itemsPerPage>
|
||||
<openSearch:startIndex>1</openSearch:startIndex>
|
||||
|
||||
${videos.map(video => html`
|
||||
<entry gd:etag='W/"${video.id}."'>
|
||||
<id>tag:youtube.com,2008:playlist:${video.id}:${video.id}</id>
|
||||
<published>${video.created}</published>
|
||||
<updated>${video.created}</updated>
|
||||
<category scheme='http://schemas.google.com/g/2005#kind' term='${config.BASE_URL}/schemas/1970#video'/>
|
||||
<category scheme='${config.BASE_URL}/schemas/1970/categories.cat' term='Howto' label='Howto & Style'/>
|
||||
<title>${video.title}</title>
|
||||
<content type='application/x-shockwave-flash' src=''/>
|
||||
<link rel='alternate' type='text/html' href="http://www.youtube.com/watch?v=${video.id}&feature=youtube_gdata"/>
|
||||
<link rel='http://gdata.youtube.com/schemas/2007#video.related' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/videos/${video.id}/related"/>
|
||||
<link rel='${config.BASE_URL}/schemas/1970#mobile' type='text/html' href="http://m.youtube.com/details?v=${video.id}"/>
|
||||
<link rel='${config.BASE_URL}/schemas/1970#uploader' type='application/atom+xml' href="${config.BASE_URL}/feeds/api/users/{${video.authorId}?v=2e"/>
|
||||
<link rel="${config.BASE_URL}/schemas/2007#related" type="application/atom+xml" href="${config.BASE_URL}/feeds/api/videos/${video.id}/related" />
|
||||
<link rel='related' type='application/atom+xml' href="${config.BASE_URL}/feeds/api/videos/${video.id}?v=2"/>
|
||||
<link rel='self' type='application/atom+xml' href='${config.BASE_URL}/feeds/api/playlists/8E2186857EE27746/PLyl9mKRbpNIpJC5B8qpcgKX8v8NI62Jho?v=2'/>
|
||||
<author>
|
||||
<name>${video.authorName}</name>
|
||||
<uri>${config.BASE_URL}/</uri>
|
||||
<yt:userId>{${video.authorId}</yt:userId>
|
||||
</author>
|
||||
<yt:accessControl action='comment' permission='allowed'/>
|
||||
<yt:accessControl action='commentVote' permission='allowed'/>
|
||||
<yt:accessControl action='videoRespond' permission='moderated'/>
|
||||
<yt:accessControl action='rate' permission='allowed'/>
|
||||
<yt:accessControl action='embed' permission='allowed'/>
|
||||
<yt:accessControl action='list' permission='allowed'/>
|
||||
<yt:accessControl action='autoPlay' permission='allowed'/>
|
||||
<yt:accessControl action='syndicate' permission='allowed'/>
|
||||
<gd:comments>
|
||||
<gd:feedLink rel='${config.BASE_URL}/schemas/1970#comments' href="${config.BASE_URL}/api/videos/${video.id}/comments?v=2" countHint='5'/>
|
||||
</gd:comments>
|
||||
<gd:related>
|
||||
<gd:feedLink rel='${config.BASE_URL}/schemas/1970#related' href="${config.BASE_URL}/feeds/api/videos/${video.id}/related" countHint='5'/>
|
||||
</gd:related>
|
||||
<yt:location></yt:location>
|
||||
<media:group>
|
||||
<media:category label='Howto & Style' scheme='${config.BASE_URL}/schemas/1970/categories.cat'>Howto</media:category>
|
||||
<media:content url="http://www.youtube.com/v/${video.id}?version=3&f=playlists&app=youtube_gdata" type='application/x-shockwave-flash' medium='video' isDefault='true' expression='full' duration='0' yt:format='5'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='1'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='6'/>
|
||||
<media:credit role='uploader' scheme='urn:youtube' yt:display='${video.authorName}' yt:type='partner'>{${video.authorId}</media:credit>
|
||||
<media:description type='plain'>${video.description}</media:description>
|
||||
<media:keywords/>
|
||||
<media:license type='text/html' href='http://www.youtube.com/t/terms'>youtube</media:license>
|
||||
<media:player url="http://www.youtube.com/watch?v=${video.id}&feature=youtube_gdata_player"/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='default'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/mqdefault.jpg" height='180' width='320' yt:name='mqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/hqdefault.jpg" height='360' width='480' yt:name='hqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='start'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='middle'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='end'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/mp4" medium="video" isDefault="true" expression="full" duration="0" yt:format="3" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="2" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/mp4" medium="video" expression="full" duration="0" yt:format="8" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="9" />
|
||||
<media:title type='plain'>${video.description}}</media:title>
|
||||
<yt:duration seconds="${video.duration}"/>
|
||||
<yt:uploaded>${video.created}</yt:uploaded>
|
||||
<yt:uploaderId>${video.authorId}</yt:uploaderId>
|
||||
<yt:videoid>${video.id}</yt:videoid>
|
||||
</media:group>
|
||||
<gd:rating average='0' max='0' min='0' numRaters='0' rel='https://schemas.google.com/g/2005#overall'/>
|
||||
<yt:recorded>1970-08-22</yt:recorded>
|
||||
<yt:statistics favoriteCount='0' viewCount="${video.viewCount}"/>
|
||||
<yt:rating numDislikes='0' numLikes='0'/>
|
||||
<yt:position>1</yt:position>
|
||||
</entry>
|
||||
`)}
|
||||
</feed>
|
||||
`
|
||||
@ -1,9 +1,9 @@
|
||||
import { html } from "hono/html";
|
||||
import { config } from "../lib/config.js";
|
||||
import { YTNodes } from "youtubei.js";
|
||||
import type { Video } from "../lib/utils/video.js";
|
||||
|
||||
export default (
|
||||
videos: YTNodes.Video[],
|
||||
videos: Video[],
|
||||
nextPage: string
|
||||
) => html`
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
@ -40,23 +40,23 @@ export default (
|
||||
|
||||
${videos.map(video => html`
|
||||
<entry gd:etag='W/"YDwqeyM."'>
|
||||
<id>tag:youtube.com,2008:playlist:${video.video_id}:${video.video_id}</id>
|
||||
<published>${new Date().toISOString()}</published>
|
||||
<updated>${new Date().toISOString()}</updated>
|
||||
<id>tag:youtube.com,2008:playlist:${video.id}:${video.id}</id>
|
||||
<published>${video.created}</published>
|
||||
<updated>${video.created}</updated>
|
||||
<category scheme='https://schemas.google.com/g/2005#kind' term='https://gdata.youtube.com/schemas/1970#video'/>
|
||||
<category scheme='https://gdata.youtube.com/schemas/1970/categories.cat' term='Howto' label='Howto & Style'/>
|
||||
<title>${video.title}</title>
|
||||
<content type='application/x-shockwave-flash' src="https://www.youtube.com/v/${video.video_id}?version=3&f=playlists&app=youtube_gdata"/>
|
||||
<link rel='alternate' type='text/html' href="https://www.youtube.com/watch?v=${video.video_id}&feature=youtube_gdata"/>
|
||||
<link rel='http://gdata.youtube.com/schemas/2007#video.related' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/videos/${video.video_id}/related"/>
|
||||
<link rel='https://gdata.youtube.com/schemas/1970#mobile' type='text/html' href="https://m.youtube.com/details?v=${video.video_id}"/>
|
||||
<link rel='https://gdata.youtube.com/schemas/1970#uploader' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/users/${video.author.id}?v=2"/>
|
||||
<link rel='related' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/videos/${video.video_id}?v=2"/>
|
||||
<content type='application/x-shockwave-flash' src="https://www.youtube.com/v/${video.id}?version=3&f=playlists&app=youtube_gdata"/>
|
||||
<link rel='alternate' type='text/html' href="https://www.youtube.com/watch?v=${video.id}&feature=youtube_gdata"/>
|
||||
<link rel='http://gdata.youtube.com/schemas/2007#video.related' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/videos/${video.id}/related"/>
|
||||
<link rel='https://gdata.youtube.com/schemas/1970#mobile' type='text/html' href="https://m.youtube.com/details?v=${video.id}"/>
|
||||
<link rel='https://gdata.youtube.com/schemas/1970#uploader' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/users/${video.authorId}?v=2"/>
|
||||
<link rel='related' type='application/atom+xml' href="https://gdata.youtube.com/feeds/api/videos/${video.id}?v=2"/>
|
||||
<link rel='self' type='application/atom+xml' href='https://gdata.youtube.com/feeds/api/playlists/8E2186857EE27746/PLyl9mKRbpNIpJC5B8qpcgKX8v8NI62Jho?v=2'/>
|
||||
<author>
|
||||
<name>${video.author.name}</name>
|
||||
<uri>https://gdata.youtube.com/feeds/api/users/${video.author.id}</uri>
|
||||
<yt:userId>${video.author.id}</yt:userId>
|
||||
<name>${video.authorName}</name>
|
||||
<uri>https://gdata.youtube.com/feeds/api/users/${video.authorId}</uri>
|
||||
<yt:userId>${video.authorId}</yt:userId>
|
||||
</author>
|
||||
<yt:accessControl action='comment' permission='allowed'/>
|
||||
<yt:accessControl action='commentVote' permission='allowed'/>
|
||||
@ -67,38 +67,38 @@ export default (
|
||||
<yt:accessControl action='autoPlay' permission='allowed'/>
|
||||
<yt:accessControl action='syndicate' permission='allowed'/>
|
||||
<gd:comments>
|
||||
<gd:feedLink rel='https://gdata.youtube.com/schemas/1970#comments' href="${config.BASE_URL}/api/videos/${video.video_id}/comments?v=2" countHint='5'/>
|
||||
<gd:feedLink rel='https://gdata.youtube.com/schemas/1970#comments' href="${config.BASE_URL}/api/videos/${video.id}/comments?v=2" countHint='5'/>
|
||||
</gd:comments>
|
||||
<yt:location>Paris ,FR</yt:location>
|
||||
<media:group>
|
||||
<media:category label='Howto & Style' scheme='https://gdata.youtube.com/schemas/1970/categories.cat'>Howto</media:category>
|
||||
<media:content url="https://www.youtube.com/v/${video.video_id}?version=3&f=playlists&app=youtube_gdata" type='application/x-shockwave-flash' medium='video' isDefault='true' expression='full' duration='0' yt:format='5'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='1'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='6'/>
|
||||
<media:credit role='uploader' scheme='urn:youtube' yt:display="${video.author.name}" yt:type='partner'>${video.author.id}</media:credit>
|
||||
<media:content url="https://www.youtube.com/v/${video.id}?version=3&f=playlists&app=youtube_gdata" type='application/x-shockwave-flash' medium='video' isDefault='true' expression='full' duration='0' yt:format='5'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='1'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type='video/3gpp' type='video/3gpp' medium='video' expression='full' duration='0' yt:format='6'/>
|
||||
<media:credit role='uploader' scheme='urn:youtube' yt:display="${video.authorName}" yt:type='partner'>${video.authorId}</media:credit>
|
||||
<media:description type='plain'>${video.description}</media:description>
|
||||
<media:keywords/>
|
||||
<media:license type='text/html' href='https://www.youtube.com/t/terms'>youtube</media:license>
|
||||
<media:player url="https://www.youtube.com/watch?v=${video.video_id}&feature=youtube_gdata_player"/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='default'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/mqdefault.jpg" height='180' width='320' yt:name='mqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/mqdefault.jpg" height='360' width='480' yt:name='hqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='start'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='middle'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.video_id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='end'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type="video/mp4" medium="video" isDefault="true" expression="full" duration="0" yt:format="3" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="2" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type="video/mp4" medium="video" expression="full" duration="0" yt:format="8" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.video_id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="9" />
|
||||
<media:player url="https://www.youtube.com/watch?v=${video.id}&feature=youtube_gdata_player"/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='default'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/mqdefault.jpg" height='180' width='320' yt:name='mqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/mqdefault.jpg" height='360' width='480' yt:name='hqdefault'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='start'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='middle'/>
|
||||
<media:thumbnail url="https://i.ytimg.com/vi/${video.id}/default.jpg" height='90' width='120' time='00:00:00.000' yt:name='end'/>
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/mp4" medium="video" isDefault="true" expression="full" duration="0" yt:format="3" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="2" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/mp4" medium="video" expression="full" duration="0" yt:format="8" />
|
||||
<media:content url="${config.BASE_URL}/getvideo/${video.id}" type="video/3gpp" medium="video" expression="full" duration="0" yt:format="9" />
|
||||
<media:title type='plain'>${video.title}</media:title>
|
||||
<yt:duration seconds="${video.length_text?.text}"/>
|
||||
<yt:uploaded>${new Date().toISOString()}</yt:uploaded>
|
||||
<yt:uploaderId>${video.author.id}</yt:uploaderId>
|
||||
<yt:videoid>${video.video_id}</yt:videoid>
|
||||
<yt:duration seconds="${video.duration}"/>
|
||||
<yt:uploaded>${video.created}</yt:uploaded>
|
||||
<yt:uploaderId>${video.authorId}</yt:uploaderId>
|
||||
<yt:videoid>${video.id}</yt:videoid>
|
||||
</media:group>
|
||||
<gd:rating average='0' max='0' min='0' numRaters='0' rel='https://schemas.google.com/g/2005#overall'/>
|
||||
<yt:recorded>1970-08-22</yt:recorded>
|
||||
<yt:statistics favoriteCount='0' viewCount="${video.view_count?.text}"/>
|
||||
<yt:statistics favoriteCount='0' viewCount="${video.viewCount}"/>
|
||||
<yt:rating numDislikes='100' numLikes='10000'/>
|
||||
<yt:position>1</yt:position>
|
||||
</entry>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user