From 0f8bf328075e379e765448f3c3e2694b78ce75bd Mon Sep 17 00:00:00 2001 From: Christian Cito Date: Tue, 5 Nov 2024 23:15:29 +0100 Subject: [PATCH] initial commit --- .env.example | 11 + .eslintrc | 3 + .gitignore | 13 + .prettierrc | 0 README.md | 9 + app/entry.client.tsx | 22 + app/entry.server.tsx | 29 + app/hooks/use-action-url.ts | 55 + app/hooks/use-url-state.ts | 94 + app/lib/get-active-tags-by-slugs.ts | 17 + app/lib/get-stream-info.server.ts | 60 + app/lib/get-videos.ts | 138 + app/root.tsx | 166 + app/routes/[robots.txt].tsx | 26 + app/routes/[sitemap.xml].tsx | 38 + app/routes/__videos.tsx | 69 + app/routes/__videos/index.tsx | 109 + app/routes/__videos/tags/$.tsx | 139 + app/routes/api/add-top-of-the-hour-rating.ts | 44 + app/routes/api/get-tags-for-sidebar.ts | 40 + app/routes/api/matchTags.ts | 133 + app/routes/api/ping-turso-prisma-query.ts | 29 + app/routes/api/ping-turso-prisma.ts | 11 + app/routes/api/ping-turso.ts | 26 + app/routes/api/syncChannel.ts | 184 + app/routes/api/syncChannels.ts | 69 + app/routes/api/syncNewVideos.ts | 77 + app/routes/api/syncVideos.ts | 129 + app/routes/stats.tsx | 119 + app/styles/app.css | 1468 ++ app/sync/clients/youtube-api.server.ts | 18 + app/sync/clients/youtube-rss.server.ts | 25 + app/sync/services/matching.ts | 40 + app/sync/validators/rapid-api.server.ts | 105 + app/sync/validators/youtube-rss.server.ts | 21 + app/ui/header.tsx | 114 + app/ui/layout.tsx | 23 + app/ui/loading-spinner.tsx | 33 + app/ui/logo.tsx | 12 + app/ui/sidebar.tsx | 107 + app/ui/tag-button.tsx | 37 + app/ui/taglist.tsx | 21 + app/ui/video-grid-item.tsx | 116 + app/ui/videos-grid.tsx | 187 + app/utils/dbEnums.ts | 9 + app/utils/debug.server.ts | 5 + app/utils/index.ts | 20 + app/utils/prisma.server.ts | 11 + app/utils/validators.ts | 34 + notes/tag-topic-mapping.md | 111 + package-lock.json | 15760 +++++++++++++++++ package.json | 54 + postcss.config.js | 6 + prisma/schema.prisma | 118 + prisma/seed.ts | 199 + public/android-chrome-192x192.png | Bin 0 -> 50889 bytes public/android-chrome-384x384.png | Bin 0 -> 154136 bytes public/apple-touch-icon.png | Bin 0 -> 45877 bytes public/browserconfig.xml | 9 + public/favicon-16x16.png | Bin 0 -> 1935 bytes public/favicon-32x32.png | Bin 0 -> 3140 bytes public/favicon.ico | Bin 0 -> 12014 bytes public/mstile-150x150.png | Bin 0 -> 30396 bytes public/og.png | Bin 0 -> 27500 bytes public/safari-pinned-tab.svg | 198 + public/site.webmanifest | 19 + remix.config.js | 15 + remix.env.d.ts | 2 + server.js | 4 + styles/app.css | 56 + tailwind.config.js | 18 + tsconfig.json | 22 + vercel.json | 30 + 73 files changed, 20886 insertions(+) create mode 100644 .env.example create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 app/entry.client.tsx create mode 100644 app/entry.server.tsx create mode 100644 app/hooks/use-action-url.ts create mode 100644 app/hooks/use-url-state.ts create mode 100644 app/lib/get-active-tags-by-slugs.ts create mode 100644 app/lib/get-stream-info.server.ts create mode 100644 app/lib/get-videos.ts create mode 100644 app/root.tsx create mode 100644 app/routes/[robots.txt].tsx create mode 100644 app/routes/[sitemap.xml].tsx create mode 100644 app/routes/__videos.tsx create mode 100644 app/routes/__videos/index.tsx create mode 100644 app/routes/__videos/tags/$.tsx create mode 100644 app/routes/api/add-top-of-the-hour-rating.ts create mode 100644 app/routes/api/get-tags-for-sidebar.ts create mode 100644 app/routes/api/matchTags.ts create mode 100644 app/routes/api/ping-turso-prisma-query.ts create mode 100644 app/routes/api/ping-turso-prisma.ts create mode 100644 app/routes/api/ping-turso.ts create mode 100644 app/routes/api/syncChannel.ts create mode 100644 app/routes/api/syncChannels.ts create mode 100644 app/routes/api/syncNewVideos.ts create mode 100644 app/routes/api/syncVideos.ts create mode 100644 app/routes/stats.tsx create mode 100644 app/styles/app.css create mode 100644 app/sync/clients/youtube-api.server.ts create mode 100644 app/sync/clients/youtube-rss.server.ts create mode 100644 app/sync/services/matching.ts create mode 100644 app/sync/validators/rapid-api.server.ts create mode 100644 app/sync/validators/youtube-rss.server.ts create mode 100644 app/ui/header.tsx create mode 100644 app/ui/layout.tsx create mode 100644 app/ui/loading-spinner.tsx create mode 100644 app/ui/logo.tsx create mode 100644 app/ui/sidebar.tsx create mode 100644 app/ui/tag-button.tsx create mode 100644 app/ui/taglist.tsx create mode 100644 app/ui/video-grid-item.tsx create mode 100644 app/ui/videos-grid.tsx create mode 100644 app/utils/dbEnums.ts create mode 100644 app/utils/debug.server.ts create mode 100644 app/utils/index.ts create mode 100644 app/utils/prisma.server.ts create mode 100644 app/utils/validators.ts create mode 100644 notes/tag-topic-mapping.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.js create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed.ts create mode 100644 public/android-chrome-192x192.png create mode 100644 public/android-chrome-384x384.png create mode 100644 public/apple-touch-icon.png create mode 100644 public/browserconfig.xml create mode 100644 public/favicon-16x16.png create mode 100644 public/favicon-32x32.png create mode 100644 public/favicon.ico create mode 100644 public/mstile-150x150.png create mode 100644 public/og.png create mode 100644 public/safari-pinned-tab.svg create mode 100644 public/site.webmanifest create mode 100644 remix.config.js create mode 100644 remix.env.d.ts create mode 100644 server.js create mode 100644 styles/app.css create mode 100644 tailwind.config.js create mode 100644 tsconfig.json create mode 100644 vercel.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ae6c25a --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +TURSO_URL= +TURSO_TOKEN= +DATABASE_URL= + +YOUTUBE_API_KEY= + +TWITCH_CLIENT_ID= +TWITCH_CLIENT_SECRET= +TWITCH_ACCESS_TOKEN= + +TOP_OF_THE_HOUR_SECRET= \ No newline at end of file diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..7156975 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,3 @@ +{ + "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ad8808 --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +node_modules + +.cache +.env +.vercel +.output + +/build/ +/public/build +/api + +.DS_Store +.pscale.yml \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..526191d --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# Hasanhub + +## Refresh access token + +```bash +curl -X POST 'https://id.twitch.tv/oauth2/token' \ +-H 'Content-Type: application/x-www-form-urlencoded' \ +-d 'client_id=ttsgjnui5mqji4aqjvzahh3mrlyzy3&client_secret=[CLIENT_SECRET]&grant_type=client_credentials' +``` diff --git a/app/entry.client.tsx b/app/entry.client.tsx new file mode 100644 index 0000000..a56c5ba --- /dev/null +++ b/app/entry.client.tsx @@ -0,0 +1,22 @@ +import { RemixBrowser } from "@remix-run/react"; +import { hydrate } from "react-dom"; +// import { useLocation, useMatches } from "@remix-run/react"; +// import * as Sentry from "@sentry/remix"; +// import { useEffect } from "react"; + +// Sentry.init({ +// dsn: "https://5c4951b4713443e18cb2e5871d45a782@o1293114.ingest.sentry.io/6564125", +// tracesSampleRate: 1, +// integrations: [ +// new Sentry.BrowserTracing({ +// routingInstrumentation: Sentry.remixRouterInstrumentation( +// useEffect, +// useLocation, +// useMatches +// ), +// }), +// ], +// // ... +// }); + +hydrate(, document); diff --git a/app/entry.server.tsx b/app/entry.server.tsx new file mode 100644 index 0000000..81c7d2d --- /dev/null +++ b/app/entry.server.tsx @@ -0,0 +1,29 @@ +import type { EntryContext } from "@remix-run/node"; +import { RemixServer } from "@remix-run/react"; +import { renderToString } from "react-dom/server"; +// import * as Sentry from "@sentry/remix"; +// import { prisma } from "~/utils/prisma.server"; + +// Sentry.init({ +// dsn: "https://5c4951b4713443e18cb2e5871d45a782@o1293114.ingest.sentry.io/6564125", +// tracesSampleRate: 1, +// integrations: [new Sentry.Integrations.Prisma({ client: prisma })], +// }); + +export default function handleRequest( + request: Request, + responseStatusCode: number, + responseHeaders: Headers, + remixContext: EntryContext +) { + let markup = renderToString( + + ); + + responseHeaders.set("Content-Type", "text/html"); + + return new Response("" + markup, { + status: responseStatusCode, + headers: responseHeaders, + }); +} diff --git a/app/hooks/use-action-url.ts b/app/hooks/use-action-url.ts new file mode 100644 index 0000000..102d0fd --- /dev/null +++ b/app/hooks/use-action-url.ts @@ -0,0 +1,55 @@ +import useUrlState from "~/hooks/use-url-state"; +import type { DurationType } from "~/utils/validators"; +import type { OrderByType, OrderDirectionType } from "../utils/validators"; + +const useActionUrl = () => { + const current = useUrlState(); + + const constructUrl = ( + action: { + tagSlugs?: string[]; + durations?: DurationType[]; + ordering?: { by?: OrderByType; order?: OrderDirectionType }; + lastVideoId?: number; + }, + index = false + ) => { + let merged = { + ...current, + ...action, + }; + merged.tagSlugs = merged?.tagSlugs?.filter(Boolean); + merged.durations = merged?.durations?.filter(Boolean); + + const basePath = + merged.tagSlugs.length > 0 + ? `/tags/${merged.tagSlugs.join("/")}?` + : index + ? "?index&" + : "/?"; + + const searchParams = new URLSearchParams(); + + merged.durations?.forEach((duration: DurationType) => { + searchParams.append("durations", duration); + }); + + if (merged.ordering.order && merged.ordering.order !== "desc") { + searchParams.append("order", merged.ordering.order); + } + + if (merged.ordering.by && merged.ordering.by !== "publishedAt") { + searchParams.append("by", merged.ordering.by); + } + + if (merged.lastVideoId) { + searchParams.append("lastVideoId", merged.lastVideoId.toString()); + } + + return `${basePath}${searchParams.toString()}`; + }; + + return { current, constructUrl }; +}; + +export default useActionUrl; diff --git a/app/hooks/use-url-state.ts b/app/hooks/use-url-state.ts new file mode 100644 index 0000000..a0df8f5 --- /dev/null +++ b/app/hooks/use-url-state.ts @@ -0,0 +1,94 @@ +import { useLocation, useSearchParams, useTransition } from "@remix-run/react"; +import { useEffect, useState } from "react"; +import type { + LastVideoIdType, + DurationListType, + OrderByType, + OrderDirectionType, +} from "../utils/validators"; +import { UrlParamsSchema, DurationListValidator } from "../utils/validators"; + +const getTagSlugsFromPathname = (location?: string | null) => { + if (location === null || location === undefined) { + return []; + } + + return location.replace("/tags/", "").split("/"); +}; + +type UrlStateType = { + tagSlugs: string[]; + lastVideoId?: LastVideoIdType; + durations?: DurationListType; + ordering: { + by: OrderByType; + order: OrderDirectionType; + }; +}; + +const useUrlState = () => { + const location = useLocation(); + const [searchParams] = useSearchParams(); + + const [urlState, setUrlState] = useState({ + tagSlugs: getTagSlugsFromPathname(location?.pathname), + durations: + DurationListValidator.parse(searchParams.getAll("durations")) ?? null, + ordering: { + order: "desc", + by: "publishedAt", + }, + }); + + const transition = useTransition(); + + useEffect(() => { + const nextSearchParams = new URLSearchParams(transition.location?.search); + + let lastVideoIdParam = searchParams.get("lastVideoId"); + let nextLastVideoIdParam = nextSearchParams.get("lastVideoId"); + + const tagSlugs = getTagSlugsFromPathname(location?.pathname); + const nextTagSlugs = getTagSlugsFromPathname( + transition?.location?.pathname + ); + + const { order, durations, by, lastVideoId } = UrlParamsSchema.parse({ + order: searchParams.get("order") ?? undefined, + durations: searchParams.getAll("durations"), + by: searchParams.get("by") ?? undefined, + lastVideoId: lastVideoIdParam ? parseInt(lastVideoIdParam) : undefined, + }); + + const { + order: nextOrder, + durations: nextDurations, + by: nextBy, + lastVideoId: nextLastVideoId, + } = UrlParamsSchema.parse({ + order: nextSearchParams.get("order") ?? undefined, + durations: nextSearchParams.getAll("durations"), + by: nextSearchParams.get("by") ?? undefined, + lastVideoId: nextLastVideoIdParam + ? parseInt(nextLastVideoIdParam) + : undefined, + }); + + setUrlState({ + durations: nextDurations?.length !== 0 ? nextDurations : durations, + ordering: { + order: nextOrder ?? order ?? "desc", + by: nextBy ?? by ?? "publishedAt", + }, + lastVideoId: nextLastVideoId ?? lastVideoId, + tagSlugs: nextTagSlugs.length !== 0 ? nextTagSlugs : tagSlugs, + }); + }, [location, transition.location, searchParams]); + + return { + isLoading: transition.state === "loading", + ...urlState, + }; +}; + +export default useUrlState; diff --git a/app/lib/get-active-tags-by-slugs.ts b/app/lib/get-active-tags-by-slugs.ts new file mode 100644 index 0000000..e17c123 --- /dev/null +++ b/app/lib/get-active-tags-by-slugs.ts @@ -0,0 +1,17 @@ +import { prisma } from "~/utils/prisma.server"; +const getActiveTagsBySlugs = async (tagSlugs: string[] | undefined) => { + return tagSlugs + ? await prisma.tag.findMany({ + where: { + slug: { in: tagSlugs }, + }, + select: { + id: true, + name: true, + slug: true, + }, + }) + : []; +}; + +export default getActiveTagsBySlugs; diff --git a/app/lib/get-stream-info.server.ts b/app/lib/get-stream-info.server.ts new file mode 100644 index 0000000..d280262 --- /dev/null +++ b/app/lib/get-stream-info.server.ts @@ -0,0 +1,60 @@ +export type StreamInfo = { + data: { + id: string; + user_id: string; + user_login: string; + user_name: string; + game_id: string; + game_name: string; + type: string; + title: string; + viewer_count: number; + started_at: Date; + language: string; + thumbnail_url: string; + tag_ids: string[]; + is_mature: boolean; + }[]; + pagination: { + cursor: string; + }; +}; + +export type StreamSchedule = { + data: { + segments: { + id: string; + start_time: Date; + end_time: Date; + title: string; + canceled_until?: any; + category: { + id: string; + name: string; + }; + is_recurring: boolean; + }[]; + broadcaster_id: string; + broadcaster_name: string; + broadcaster_login: string; + vacation?: any; + }; + pagination: {}; +}; + +export const getStreamInfo = async () => { + return await Promise.all([ + fetch(`https://api.twitch.tv/helix/streams?first=1&user_id=${207813352}`, { + headers: { + "Client-Id": process.env.TWITCH_CLIENT_ID?.trim() ?? "", + Authorization: `Bearer ${process.env.TWITCH_ACCESS_TOKEN ?? ""}`, + }, + }).then((res) => res.json()) as unknown as StreamInfo, + fetch(`https://api.twitch.tv/helix/schedule?broadcaster_id=${207813352}`, { + headers: { + "Client-Id": process.env.TWITCH_CLIENT_ID?.trim() ?? "", + Authorization: `Bearer ${process.env.TWITCH_ACCESS_TOKEN ?? ""}`, + }, + }).then((res) => res.json()) as unknown as StreamSchedule, + ]); +}; diff --git a/app/lib/get-videos.ts b/app/lib/get-videos.ts new file mode 100644 index 0000000..b424cfd --- /dev/null +++ b/app/lib/get-videos.ts @@ -0,0 +1,138 @@ +import { z } from "zod"; +import { publishStatus, videoSyncStatus } from "~/utils/dbEnums"; +import { prisma } from "~/utils/prisma.server"; +import type { DurationListType, LastVideoIdType } from "~/utils/validators"; +import { + DurationListValidator, + LastVideoIdValidator, + OrderByValdiator, + OrderDirectionValidator, +} from "~/utils/validators"; + +export const TagSlugsValidator = z.optional(z.array(z.string())); + +const TakeValidator = z.optional( + z.number({ + invalid_type_error: "take must be a number", + }) +); + +type GetVideosArgs = z.infer; + +const GetVideosValidator = z.object({ + tagSlugs: TagSlugsValidator, + take: TakeValidator, + by: OrderByValdiator, + order: OrderDirectionValidator, + durations: z.optional(DurationListValidator), + lastVideoId: LastVideoIdValidator, +}); + +const getVideos = async (params: GetVideosArgs) => { + const { order, durations, by, lastVideoId, tagSlugs, take } = + GetVideosValidator.parse(params); + + let conditions: { + tags?: object; + publishedAt?: object; + views?: object; + likes?: object; + OR?: Array; + disabled: boolean; + syncStatus: typeof videoSyncStatus.Full; + publishStatus: typeof publishStatus.Published; + } = { + disabled: false, + syncStatus: videoSyncStatus.Full, + publishStatus: publishStatus.Published, + }; + + if (tagSlugs && tagSlugs.length > 0) { + conditions["tags"] = { some: { tag: { slug: { in: tagSlugs } } } }; + } + + const lastCondition = lastVideoId + ? (await getLastVideo(lastVideoId))?.[by ?? "publishedAt"] + : null; + + if (lastCondition) { + if (order === "asc") { + conditions[by ?? "publishedAt"] = { gt: lastCondition }; + } else { + conditions[by ?? "publishedAt"] = { lt: lastCondition }; + } + } + + if (durations) { + const minMaxPairs = + getMinxMaxForTimeFilter(durations)?.map((pair) => { + return { gte: pair[0], lte: pair[1] }; + }) ?? []; + if (minMaxPairs.length > 0) { + conditions["OR"] = []; + minMaxPairs.forEach((pair) => { + conditions.OR?.push({ duration: pair }); + }); + } + } + + return await prisma.$transaction([ + prisma.video.findMany({ + select: { + id: true, + youtubeId: true, + largeThumbnailUrl: true, + title: true, + publishedAt: true, + views: true, + duration: true, + channel: { + select: { + id: true, + title: true, + smallThumbnailUrl: true, + youtubeId: true, + }, + }, + tags: { + select: { tag: { select: { id: true, slug: true, name: true } } }, + }, + }, + where: conditions, + take: take ?? 25, + // include: { channel: true, tags: { include: { tag: true } } }, + orderBy: { + [by ?? "publishedAt"]: order ?? "desc", + }, + }), + prisma.video.count({ + where: conditions, + }), + ]); +}; + +const getLastVideo = async (lastVideoId: LastVideoIdType) => { + return await prisma.video.findUnique({ + where: { id: lastVideoId }, + select: { publishedAt: true, views: true, likes: true }, + }); +}; + +const getMinxMaxForTimeFilter = (durations?: DurationListType) => { + return durations?.map((duration) => { + switch (duration) { + case "short": + return [0, 60 * 3]; + case "medium": + return [60 * 3, 60 * 15]; + case "long": + return [60 * 15, 60 * 30]; + case "extralong": + return [60 * 30, 999999]; + default: + return [0, 999999999]; + } + }); +}; + +export default getVideos; diff --git a/app/root.tsx b/app/root.tsx new file mode 100644 index 0000000..b1ce242 --- /dev/null +++ b/app/root.tsx @@ -0,0 +1,166 @@ +import type { LoaderFunction, MetaFunction } from "@remix-run/node"; +import { json } from "@remix-run/node"; +import { + isRouteErrorResponse, + Links, + LiveReload, + Meta, + Outlet, + Scripts, + ScrollRestoration, + useLoaderData, + useRouteError, +} from "@remix-run/react"; +import Layout from "./ui/layout"; +import styles from "./styles/app.css"; +import { getStreamInfo } from "./lib/get-stream-info.server"; + +export const meta: MetaFunction = () => ({ + charset: "utf-8", + title: "HasanHub – All clips from Hasanabi streams", + viewport: "width=device-width,initial-scale=1", + description: + "The HasanAbi Clips Industrial Complex App. All clips from 70+ Hasan Piker channels.", + keywords: "hasanabi, hasanhub, hasan piker, streamer, youtube, clips, twitch", + "msapplication-tileColor": "#da532c", + "theme-color": "#7a55b1", + "yandex-verification": "45afda70569d2af8", +}); + +export function headers() { + return { + "Cache-Control": "max-age=60, s-maxage=60, stale-while-revalidate=360", + }; +} + +export function links() { + return [ + { rel: "stylesheet", href: styles }, + { rel: "icon", href: "/favicon.ico" }, + { + rel: "apple-touch-icon", + size: "180x180x", + href: "/apple-touch-icon.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "32x32", + href: "/favicon-32x32.png", + }, + { + rel: "icon", + type: "image/png", + sizes: "16x16", + href: "/favicon-16x16.png", + }, + { + rel: "mask-icon", + href: "/safari-pinned-tab.svg", + color: "#5bbad5", + }, + { + rel: "manifest", + href: "site.webmanifest", + }, + { + rel: "preconnect", + href: " https://i.ytimg.com", + }, + { + rel: "dns-prefetch", + href: " https://i.ytimg.com", + }, + ]; +} + +export async function loader() { + const [streamInfo, schedule] = await getStreamInfo(); + + return json( + { + streamInfo: streamInfo.data?.length + ? { + user_login: streamInfo.data[0].user_login, + user_name: streamInfo.data[0].user_name, + title: streamInfo.data[0].title, + } + : null, + schedule: schedule.data?.segments.length + ? { + broadcaster_login: schedule.data.broadcaster_login, + broadcaster_name: schedule.data.broadcaster_name, + start_time: schedule.data.segments[0].start_time, + title: schedule.data.segments[0].title, + } + : null, + }, + { + status: 200, + headers: { + "Cache-Control": "max-age=60, s-maxage=60, stale-while-revalidate=360", + }, + } + ); +} + +function App() { + const { streamInfo, schedule } = useLoaderData(); + + return ( + + + + + + + + + + + + + + + +