soline init
This commit is contained in:
+60
@@ -0,0 +1,60 @@
|
||||
FROM node:25-alpine AS base
|
||||
|
||||
FROM base AS deps
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* bun.lock* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
elif [ -f bun.lock ]; then npm install -g bun && bun install --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
ARG VITE_PROXY_PREFIX
|
||||
ARG VITE_PROXY_PREFIX_VAMANA
|
||||
ARG VITE_PROXY_PREFIX_BALARAMA
|
||||
|
||||
# 2. Assign ARGs to ENV so Vite/Vinxi can see them
|
||||
ENV VITE_PROXY_PREFIX=$VITE_PROXY_PREFIX
|
||||
ENV VITE_PROXY_PREFIX_VAMANA=$VITE_PROXY_PREFIX_VAMANA
|
||||
ENV VITE_PROXY_PREFIX_BALARAMA=$VITE_PROXY_PREFIX_BALARAMA
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f bun.lock ]; then npm run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 appuser
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=appuser:nodejs /app/.output ./.output
|
||||
COPY --from=builder --chown=appuser:nodejs /app/.vinxi ./.vinxi
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
|
||||
USER appuser
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,32 @@
|
||||
# SolidStart
|
||||
|
||||
Everything you need to build a Solid project, powered by [`solid-start`](https://start.solidjs.com);
|
||||
|
||||
## Creating a project
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npm init solid@latest
|
||||
|
||||
# create a new project in my-app
|
||||
npm init solid@latest my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
Solid apps are built with _presets_, which optimise your project for deployment to different environments.
|
||||
|
||||
By default, `npm run build` will generate a Node app that you can run with `npm start`. To use a different preset, add it to the `devDependencies` in `package.json` and specify in your `app.config.js`.
|
||||
|
||||
## This project was created with the [Solid CLI](https://github.com/solidjs-community/solid-cli)
|
||||
@@ -0,0 +1,37 @@
|
||||
import { defineConfig } from "@solidjs/start/config";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { type InlineConfig } from "vite";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const app = defineConfig({
|
||||
middleware: "src/middleware/index.ts",
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
assetsInlineLimit: 1024 * 1024,
|
||||
},
|
||||
dev: {
|
||||
chunkSizeWarningLimit: 1000,
|
||||
},
|
||||
} as InlineConfig,
|
||||
});
|
||||
|
||||
app.addRouter({
|
||||
name: "hono",
|
||||
type: "http",
|
||||
base: "/api/hono",
|
||||
handler: "./src/hono.ts",
|
||||
worker: true,
|
||||
});
|
||||
|
||||
export default app;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { em, entity, text, boolean } from "bknd";
|
||||
|
||||
import { registerLocalMediaAdapter } from "bknd/adapter/node";
|
||||
import { RuntimeBkndConfig } from "bknd/adapter";
|
||||
|
||||
const local = registerLocalMediaAdapter();
|
||||
// let html = await readFile("./index.html", "utf-8");
|
||||
|
||||
const schema = em({
|
||||
todos: entity("todos", {
|
||||
title: text(),
|
||||
done: boolean(),
|
||||
}),
|
||||
});
|
||||
|
||||
// register your schema to get automatic type completion
|
||||
type Database = (typeof schema)["DB"];
|
||||
declare module "bknd" {
|
||||
interface DB extends Database {}
|
||||
}
|
||||
|
||||
export default {
|
||||
connection: {
|
||||
url: "file:data.db",
|
||||
},
|
||||
options: {
|
||||
// the seed option is only executed if the database was empty
|
||||
seed: async (ctx) => {
|
||||
// create some entries
|
||||
await ctx.em.mutator("todos").insertMany([
|
||||
{ title: "Learn bknd", done: true },
|
||||
{ title: "Build something cool", done: false },
|
||||
]);
|
||||
|
||||
// and create a user
|
||||
await ctx.app.module.auth.createUser({
|
||||
email: "test@bknd.io",
|
||||
password: "12345678",
|
||||
});
|
||||
},
|
||||
},
|
||||
config: {
|
||||
data: schema.toJSON(),
|
||||
auth: {
|
||||
enabled: true,
|
||||
jwt: {
|
||||
alg: "HS256",
|
||||
secret: "e79f40806aee76b95f0e2e5789eea",
|
||||
},
|
||||
},
|
||||
media: {
|
||||
enabled: true,
|
||||
adapter: local({
|
||||
path: "./public/uploads",
|
||||
}),
|
||||
},
|
||||
},
|
||||
adminOptions: {
|
||||
adminBasepath: "/admin",
|
||||
assetsPath: "/admin/",
|
||||
},
|
||||
} satisfies RuntimeBkndConfig;
|
||||
@@ -0,0 +1,28 @@
|
||||
// https://zaidan.carere.dev/ui/button
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "kobalte",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {
|
||||
"@zaidan": "https://zaidan.carere.dev/r/{style}/{name}.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
services:
|
||||
redis:
|
||||
image: redis:alpine
|
||||
container_name: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
restart: always
|
||||
postgres:
|
||||
image: postgres:18-alpine
|
||||
container_name: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
restart: always
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
args:
|
||||
- VITE_PROXY_PREFIX=${VITE_PROXY_PREFIX}
|
||||
- VITE_PROXY_PREFIX_VAMANA=${VITE_PROXY_PREFIX_VAMANA}
|
||||
- VITE_PROXY_PREFIX_BALARAMA=${VITE_PROXY_PREFIX_BALARAMA}
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- 3000:3000
|
||||
depends_on:
|
||||
- redis
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "example-basic",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "vite start",
|
||||
"preview": "vite preview",
|
||||
"typegen": "bknd types --outfile bknd-types.d.ts",
|
||||
"prebuild": "bknd copy-assets --out public/admin"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/barlow-condensed": "^5.2.8",
|
||||
"@solidjs/h": "^2.0.0-experimental.16",
|
||||
"@solidjs/html": "^2.0.0-experimental.16",
|
||||
"@solidjs/meta": "^0.29.4",
|
||||
"@solidjs/router": "^0.15.4",
|
||||
"@solidjs/start": "2.0.0-alpha.2",
|
||||
"@solidjs/web": "^2.0.0-experimental.16",
|
||||
"@solidjs/universal": "^2.0.0-experimental.16",
|
||||
"@solidjs/vite-plugin-nitro-2": "^0.1.0",
|
||||
"babel-preset-solid": "^2.0.0-experimental.16",
|
||||
"vite-plugin-solid": "3.0.0-next.1",
|
||||
"@trpc/client": "11.10.0",
|
||||
"@trpc/server": "11.10.0",
|
||||
"@types/node": "^25.3.5",
|
||||
"artplayer": "^5.3.0",
|
||||
"artplayer-plugin-chapter": "^1.0.1",
|
||||
"bknd": "^0.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dexie": "^4.3.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"hono": "^4.12.5",
|
||||
"ioredis": "^5.10.0",
|
||||
"lucide-react": "^0.564.0",
|
||||
"lucide-solid": "^0.563.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"simple-icons": "^16.10.0",
|
||||
"solid-js": "^1.9.5",
|
||||
"solid-toast": "^0.5.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vite": "^7.3.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
"shadcn": "^3.8.5",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite-plugin-environment": "^1.1.3",
|
||||
"vite-plugin-rewrite-all": "^1.0.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"src/ui/components/code/CodeEditor.tsx": {
|
||||
"file": "assets/CodeEditor-BSbZC6_t.js",
|
||||
"name": "CodeEditor",
|
||||
"src": "src/ui/components/code/CodeEditor.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"src/ui/main.tsx"
|
||||
]
|
||||
},
|
||||
"src/ui/components/form/json-schema/JsonSchemaForm.tsx": {
|
||||
"file": "assets/JsonSchemaForm-CE_Yijem.js",
|
||||
"name": "JsonSchemaForm",
|
||||
"src": "src/ui/components/form/json-schema/JsonSchemaForm.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"src/ui/main.tsx"
|
||||
]
|
||||
},
|
||||
"src/ui/main.tsx": {
|
||||
"file": "assets/main-CfjI0j-e.js",
|
||||
"name": "main",
|
||||
"src": "src/ui/main.tsx",
|
||||
"isEntry": true,
|
||||
"dynamicImports": [
|
||||
"src/ui/components/form/json-schema/JsonSchemaForm.tsx",
|
||||
"src/ui/components/code/CodeEditor.tsx",
|
||||
"src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx",
|
||||
"src/ui/components/code/CodeEditor.tsx"
|
||||
],
|
||||
"css": [
|
||||
"assets/main-OPOTedc8.css"
|
||||
]
|
||||
},
|
||||
"src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx": {
|
||||
"file": "assets/DataSchemaCanvas-Bidf4HWW.js",
|
||||
"name": "DataSchemaCanvas",
|
||||
"src": "src/ui/modules/data/components/canvas/DataSchemaCanvas.tsx",
|
||||
"isDynamicEntry": true,
|
||||
"imports": [
|
||||
"src/ui/main.tsx"
|
||||
]
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
+158
@@ -0,0 +1,158 @@
|
||||
/* @import url('https://fonts.googleapis.com/css2?family=Barlow+Condensed:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap'); */
|
||||
@import '@fontsource/barlow-condensed';
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
|
||||
@theme {
|
||||
--font-barlow-condensed: "Barlow Condensed", sans-serif;
|
||||
|
||||
--text-xs: 0.750rem;
|
||||
--text-sm: 1rem;
|
||||
--text-base: 1.5rem;
|
||||
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-barlow-condensed);
|
||||
--font-mono: var(--font-barlow-condensed);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.205 0 0);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Barlow Condensed', sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground font-normal;
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.922 0 0);
|
||||
--primary-foreground: oklch(0.205 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { MetaProvider, Title } from "@solidjs/meta";
|
||||
import { A, Router } from "@solidjs/router";
|
||||
import { FileRoutes } from "@solidjs/start/router";
|
||||
import { Loading } from "solid-js";
|
||||
import "./app.css";
|
||||
import Button from "./components/button";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<Title>Home</Title>
|
||||
<div class="max-w-7xl mx-auto min-h-full p-2 md:p-4">
|
||||
<Navbar />
|
||||
<div class="mt-16">
|
||||
<Loading>{props.children}</Loading>
|
||||
</div>
|
||||
</div>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
);
|
||||
}
|
||||
|
||||
function Navbar() {
|
||||
return (
|
||||
<nav class="fixed top-4 left-0 w-full ">
|
||||
<div class=" max-w-7xl mx-auto z-50 backdrop-blur-xs px-2 md:px-4">
|
||||
<div class="bg-black/5 flex justify-between p-1 ">
|
||||
<div>
|
||||
<Button asLink href="/" size="lg">
|
||||
Home
|
||||
</Button>
|
||||
<Button asLink href="/genre" size="lg">
|
||||
Genre
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<Button asLink href="/history" size="lg">
|
||||
History
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// Refrences:
|
||||
// https://zaidan.carere.dev/ui/button
|
||||
// https://shadcn-solid.com/docs/installation/solid-start
|
||||
// https://www.solid-ui.com/docs/installation/solid-start
|
||||
// https://kobalte.dev/
|
||||
// https://corvu.dev/
|
||||
// https://zagjs.com/
|
||||
|
||||
import { JSX } from "solid-js";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { A } from "@solidjs/router";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "hover:bg-primary text-black hover:text-white hover:bg-primary/90 font-bold",
|
||||
active: "bg-primary text-white hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
type ButtonAsButton = JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asLink?: false;
|
||||
class?: string;
|
||||
};
|
||||
|
||||
type ButtonAsLink =JSX.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asLink: true;
|
||||
href: string;
|
||||
};
|
||||
|
||||
export default function Button(props: ButtonAsButton | ButtonAsLink) {
|
||||
const { children, asLink, ...rest } = props;
|
||||
|
||||
if (asLink) {
|
||||
const { href, variant, size, class: className } = props;
|
||||
return (
|
||||
<A href={href} class={cn(buttonVariants({ variant, size, className }))}>
|
||||
{children}
|
||||
</A>
|
||||
);
|
||||
}
|
||||
|
||||
const { variant, size, class: className, ...buttonProps } = rest as ButtonAsButton;
|
||||
return (
|
||||
<button class={cn(buttonVariants({ variant, size, className }))} {...buttonProps}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cn } from "~/lib/utils";
|
||||
import { createSignal, JSX, onMount, Show } from "solid-js";
|
||||
|
||||
export default function AnimatedImage(
|
||||
props: JSX.ImgHTMLAttributes<HTMLImageElement>,
|
||||
) {
|
||||
const [loaded, setLoaded] = createSignal(false);
|
||||
const [error, setError] = createSignal(false);
|
||||
let imgRef!: HTMLImageElement;
|
||||
|
||||
onMount(() => {
|
||||
if (imgRef && imgRef.complete && imgRef.naturalWidth !== 0) {
|
||||
setLoaded(true);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="grid transition-[grid-template-rows] duration-700 ease-in-out"
|
||||
style={{
|
||||
"grid-template-rows": loaded() ? "1fr" : "0fr",
|
||||
}}
|
||||
>
|
||||
<div class="overflow-hidden">
|
||||
<Show
|
||||
when={!error()}
|
||||
fallback={
|
||||
<div class="p-4 bg-gray-100 text-sm text-red-500">
|
||||
Failed to load image
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
ref={imgRef}
|
||||
{...props}
|
||||
onload={() => setLoaded(true)}
|
||||
onerror={() => setError(true)}
|
||||
class={cn(
|
||||
"w-full h-auto transition-opacity duration-500",
|
||||
loaded() ? "opacity-100" : "opacity-0",
|
||||
props.class,
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export default function Toogle({
|
||||
setChecked,
|
||||
checked,
|
||||
label,
|
||||
}: {
|
||||
label: string;
|
||||
checked: () => boolean;
|
||||
setChecked: (val: boolean) => boolean;
|
||||
}) {
|
||||
return (
|
||||
<div class="inline-flex cursor-pointer items-center gap-2 justify-between">
|
||||
<label class=" text-sm font-medium">{label}</label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked()}
|
||||
onChange={() => {
|
||||
setChecked(!checked());
|
||||
}}
|
||||
class="peer sr-only"
|
||||
/>
|
||||
<div
|
||||
role="switch"
|
||||
aria-checked={checked()}
|
||||
class="peer-focus:ring-red-700 peer relative h-[25px] w-11 border-none bg-gray-200 outline-hidden duration-200 after:absolute after:start-[2px] after:top-[2.5px] after:h-5 after:w-5 after:bg-white after:transition-all after:content-[''] peer-checked:bg-red-600 peer-checked:after:translate-x-[95%] peer-focus:outline-hidden peer-checked:rtl:after:-translate-x-full
|
||||
dark:border-gray-600 dark:bg-gray-700 dark:peer-focus:ring-red-800"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// @refresh reload
|
||||
import { mount, StartClient } from "@solidjs/start/client";
|
||||
|
||||
mount(() => <StartClient />, document.getElementById("app")!);
|
||||
@@ -0,0 +1,30 @@
|
||||
// @refresh reload
|
||||
import { createHandler, StartServer } from "@solidjs/start/server";
|
||||
|
||||
export default createHandler(() => (
|
||||
<StartServer
|
||||
document={({ assets, children, scripts }) => (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
{assets}
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">{children}</div>
|
||||
{scripts}
|
||||
</body>
|
||||
</html>
|
||||
)}
|
||||
/>
|
||||
));
|
||||
|
||||
if (typeof process !== "undefined") {
|
||||
process.on("unhandledRejection", (reason, promise) => {
|
||||
console.error("Caught Unhandled Rejection:", reason);
|
||||
});
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("Caught Uncaught Exception:", err);
|
||||
});
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="@solidjs/start/env" />
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
import { Hono } from "hono";
|
||||
import { getWebRequest } from "@solidjs/start/http";
|
||||
|
||||
const app = new Hono().basePath("/api/hono/");
|
||||
|
||||
app.get("/", (c) => c.text("ok"));
|
||||
|
||||
app.get("/proxy/*", async (c) => {
|
||||
const incomingUrl = c.req.path;
|
||||
|
||||
const parts = incomingUrl.split("/").slice(4);
|
||||
|
||||
const completeUrl = parts
|
||||
.map((part) => (part === "https:" ? part + "//" : part + "/"))
|
||||
.join("");
|
||||
|
||||
const sanitizedUrl = new URL(decodeURIComponent(completeUrl));
|
||||
|
||||
if (sanitizedUrl.host === "thunderstrike77.online") {
|
||||
sanitizedUrl.host = "haildrop77.pro";
|
||||
}
|
||||
|
||||
// const origin = c.req.header("Origin");
|
||||
// const userAgent = c.req.header("User-Agent");
|
||||
// const accept = c.req.header("Accept");
|
||||
|
||||
const res = await fetch(sanitizedUrl, {
|
||||
method: "GET",
|
||||
keepalive: true,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
origin: "localhost",
|
||||
accept:
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36",
|
||||
Referer: "https://megacloud.blog/",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: res.status, message: res.statusText }),
|
||||
{
|
||||
status: res.status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.body) {
|
||||
return new Response("No body in response", { status: 500 });
|
||||
}
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
res.headers.get("Content-Type") || "application/octet-stream",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
});
|
||||
// export default eventHandler(async (event) => {
|
||||
// const request = getWebRequest(event);
|
||||
// return await app.fetch(request);
|
||||
// });
|
||||
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
createTRPCProxyClient,
|
||||
httpBatchLink,
|
||||
loggerLink,
|
||||
} from '@trpc/client';
|
||||
import { AppRouter } from "~/trpc/api/root";
|
||||
import { getBaseUrl } from '~/utils/url';
|
||||
|
||||
|
||||
// create the client, export it
|
||||
export const api = createTRPCProxyClient<AppRouter>({
|
||||
links: [
|
||||
// will print out helpful logs when using client
|
||||
// loggerLink(),
|
||||
// identifies what url will handle trpc requests
|
||||
httpBatchLink({ url: `${getBaseUrl()}/api/trpc` })
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Redis } from 'ioredis'
|
||||
|
||||
|
||||
export const cache = new Redis(process.env.REDIS_URL!)
|
||||
|
||||
export async function cached<T>(
|
||||
key: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttl = 3600 // 1 hour default
|
||||
): Promise<T> {
|
||||
try {
|
||||
const cachedData = await cache.get(key)
|
||||
if (cachedData) {
|
||||
return JSON.parse(cachedData) as T
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Cache get error for key ${key}:`, error)
|
||||
}
|
||||
|
||||
const data = await fetcher()
|
||||
|
||||
const isAnimeInfo = key.startsWith('anime:info:')
|
||||
|
||||
try {
|
||||
await cache.set(key, JSON.stringify(data), 'EX', isAnimeInfo ? shouldCacheLonger(data as AniwatchInfo, key) ? 86400 : ttl : ttl)
|
||||
} catch (error) {
|
||||
console.error(`Cache set error for key ${key}:`, error)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
export const shouldCacheLonger = (src: AniwatchInfo, key: string) => {
|
||||
|
||||
const { moreInfo, info } = src.data.anime;
|
||||
|
||||
if (moreInfo.status === "Currently Airing") return false
|
||||
|
||||
const endedAt = moreInfo.aired.split(" to ")[1]; // ? if anime hasn't ended
|
||||
|
||||
if (!endedAt || endedAt === "?") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endedAtDate = new Date(endedAt);
|
||||
|
||||
// check if older than 6 months
|
||||
const isOld = endedAtDate.getTime() < Date.now() - 6 * 30 * 24 * 60 * 60 * 1000;
|
||||
const isVeryOld = endedAtDate.getTime() < Date.now() - 24 * 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (
|
||||
isOld &&
|
||||
moreInfo.status === "Finished Airing" &&
|
||||
info.stats.episodes.sub === info.stats.episodes.dub
|
||||
) {
|
||||
console.log(`[CACHE] ${key} cached longer has dub, sub and finished airing`);
|
||||
return true
|
||||
};
|
||||
|
||||
if (
|
||||
isOld &&
|
||||
isVeryOld &&
|
||||
moreInfo.status === "Finished Airing" &&
|
||||
info.stats.episodes.dub === 0 &&
|
||||
info.stats.episodes.sub
|
||||
) {
|
||||
console.log(`[CACHE] ${key} cached longer has sub and finished airing 2 years ago`);
|
||||
return true
|
||||
};
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import Dexie, { type EntityTable } from "dexie";
|
||||
|
||||
interface SearchHistory {
|
||||
id: number;
|
||||
type?: string;
|
||||
term: string;
|
||||
}
|
||||
|
||||
export interface UserPreference {
|
||||
id: number;
|
||||
hideWatchedShows: boolean;
|
||||
hideWatchedShowsInSearch: boolean;
|
||||
disableFloatingNavbar: boolean;
|
||||
centerContent: boolean;
|
||||
lang: "en" | "jp" | "all";
|
||||
}
|
||||
export interface WatchedShows {
|
||||
show: Anime;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface WatchLater {
|
||||
show: Anime;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface WatchHistory {
|
||||
show: Anime;
|
||||
id: string;
|
||||
time: number;
|
||||
updatedAt: Date;
|
||||
duration: number;
|
||||
ep: string;
|
||||
epNum: number | string;
|
||||
lang: "en" | "jp";
|
||||
}
|
||||
|
||||
export const indexDB = new Dexie("BunflixDB") as Dexie & {
|
||||
searches: EntityTable<SearchHistory, "id">;
|
||||
userPreferences: EntityTable<UserPreference, "id">;
|
||||
watchLater: EntityTable<WatchLater, "id">;
|
||||
watchedShows: EntityTable<WatchedShows, "id">;
|
||||
watchHistory: EntityTable<WatchHistory, "id">;
|
||||
};
|
||||
|
||||
indexDB.version(1).stores({
|
||||
searches: "++id",
|
||||
userPreferences: "id",
|
||||
watchHistory: "++id",
|
||||
watchedShows: "++id",
|
||||
watchLater: "++id",
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createMiddleware } from "@solidjs/start/middleware";
|
||||
import { createRuntimeApp, type RuntimeBkndConfig } from "bknd/adapter";
|
||||
import config from "../../bknd.config";
|
||||
|
||||
async function getApp<Env = NodeJS.ProcessEnv>(
|
||||
config: RuntimeBkndConfig<Env>,
|
||||
args: Env = process.env as Env,
|
||||
) {
|
||||
return await createRuntimeApp(config, args);
|
||||
}
|
||||
|
||||
function serve(config: RuntimeBkndConfig) {
|
||||
return async (request: Request) => {
|
||||
const app = await getApp(config, process.env);
|
||||
return app.fetch(request);
|
||||
};
|
||||
}
|
||||
|
||||
const handler = serve(config);
|
||||
|
||||
export default createMiddleware({
|
||||
onRequest: async (event) => {
|
||||
const url = new URL(event.request.url);
|
||||
const isMethodWithBody = ["POST", "PUT", "PATCH", "DELETE"].includes(
|
||||
event.request.method!,
|
||||
);
|
||||
const adminBasepath = config.adminOptions.adminBasepath;
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/api") ||
|
||||
url.pathname.startsWith(adminBasepath)
|
||||
) {
|
||||
if (url.pathname === adminBasepath + "/") {
|
||||
url.pathname = url.pathname.replace(adminBasepath + "/", adminBasepath);
|
||||
} else {
|
||||
url.pathname = url.pathname.replaceAll("//", "/");
|
||||
}
|
||||
|
||||
const modifiedRequest = new Request(url.toString(), {
|
||||
method: event.request.method,
|
||||
headers: event.request.headers as HeadersInit,
|
||||
// @ts-expect-error - 'duplex' is required for streaming bodies in Node.js
|
||||
duplex: isMethodWithBody ? "half" : undefined,
|
||||
body: isMethodWithBody ? event.request.body : undefined,
|
||||
});
|
||||
const res = await handler(modifiedRequest);
|
||||
if (res && res.status !== 404) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import { JSX } from "solid-js";
|
||||
import { Toaster } from "solid-toast";
|
||||
|
||||
export default function Layout({ children }: { children: JSX.Element }) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { HttpStatusCode } from "@solidjs/start";
|
||||
import Button from "~/components/button";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main>
|
||||
<Title>Not Found</Title>
|
||||
<HttpStatusCode code={404} />
|
||||
<main class="grid place-items-center px-6 py-24 sm:py-32 lg:px-8 min-h-[80vh]">
|
||||
<div class="text-center">
|
||||
<p class="text-base font-semibold text-red-600">404</p>
|
||||
<h1 class="mt-4 text-5xl font-semibold tracking-tight text-balance sm:text-7xl">
|
||||
Page not found
|
||||
</h1>
|
||||
<p class="mt-6 text-lg font-medium text-pretty opacity-60 sm:text-xl/8">
|
||||
Sorry, we couldn't find the page you're looking for.
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-6">
|
||||
<Button asLink variant="outline" href="/" class="">
|
||||
Go Home
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import Button from "~/components/button";
|
||||
|
||||
const GENRES = [
|
||||
'Action', 'Adventure', 'Cars',
|
||||
'Comedy', 'Dementia', 'Demons',
|
||||
'Drama', 'Ecchi', 'Fantasy',
|
||||
'Game', 'Harem', 'Historical',
|
||||
'Horror', 'Isekai', 'Josei',
|
||||
'Kids', 'Magic', 'Martial Arts',
|
||||
'Mecha', 'Military', 'Music',
|
||||
'Mystery', 'Parody', 'Police',
|
||||
'Psychological', 'Romance', 'Samurai',
|
||||
'School', 'Sci-Fi', 'Seinen',
|
||||
'Shoujo', 'Shoujo Ai', 'Shounen',
|
||||
'Shounen Ai', 'Slice of Life', 'Space',
|
||||
'Sports', 'Super Power', 'Supernatural',
|
||||
'Thriller', 'Vampire'
|
||||
] as const;
|
||||
|
||||
export default function Genre(){
|
||||
return(
|
||||
<div class="my-4">
|
||||
{GENRES.map((genre)=><Button asLink href={`/genre/${genre}`}>{genre}</Button>)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Title } from "@solidjs/meta";
|
||||
import { api } from "~/lib/api";
|
||||
import Button from "~/components/button";
|
||||
import {
|
||||
For,
|
||||
Loading,
|
||||
} from "@solidjs/web";
|
||||
|
||||
import BookmarkIcon from "lucide-solid/icons/bookmark";
|
||||
|
||||
import Genre from "./genre";
|
||||
import Image from "~/components/image";
|
||||
import toast from "solid-toast";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { createMemo, Errored } from "solid-js";
|
||||
|
||||
export default function Home() {
|
||||
|
||||
const anime = createMemo(async () => await api.anime.home.query());
|
||||
|
||||
return (
|
||||
<main>
|
||||
<Title>Home</Title>
|
||||
<div>
|
||||
<Errored
|
||||
fallback={(error, reset) => (
|
||||
<div>
|
||||
<p>{error.message}</p>
|
||||
<button onClick={reset}>Try Again</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Loading fallback="loading...">
|
||||
<h1 class="font-bold text-2xl my-8 pl-4">Spotlight Animes</h1>
|
||||
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<For each={anime()?.data.spotlightAnimes}>
|
||||
{(show) => (
|
||||
<ShowCard {...show()} href={show().id} fetchpriority="high" />
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<h1 class="font-bold text-2xl my-8 pl-4">Latest Episode Animes</h1>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<For
|
||||
each={anime()?.data.latestEpisodeAnimes.sort(
|
||||
(a, b) => a.name.length - b.name.length,
|
||||
)}
|
||||
>
|
||||
{(show) => (
|
||||
<ShowCard
|
||||
{...show()}
|
||||
href={show().id}
|
||||
class="[&_h1]:text-sm [&_img]:aspect-9/16"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<h1 class="font-bold text-2xl my-8 pl-4">Upcoming Animes</h1>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||
<For
|
||||
each={anime()?.data.topUpcomingAnimes.sort(
|
||||
(a, b) => a.name.length - b.name.length,
|
||||
)}
|
||||
>
|
||||
{(show) => (
|
||||
<ShowCard
|
||||
{...show()}
|
||||
href={show().id}
|
||||
class="[&_h1]:text-sm [&_img]:aspect-9/16"
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Genre />
|
||||
</Loading>
|
||||
</Errored>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function ShowCard(props: {
|
||||
name: string;
|
||||
description?: string;
|
||||
poster: string;
|
||||
href: string;
|
||||
id: string;
|
||||
class?: string;
|
||||
fetchpriority?: "high" | "low" | "auto" | undefined;
|
||||
episodes: {
|
||||
sub: number;
|
||||
dub: number;
|
||||
};
|
||||
}) {
|
||||
const { episodes, href, name, poster, description, fetchpriority, id } = props;
|
||||
const hasEpisodes = () => episodes.dub !== null || episodes.sub !== null;
|
||||
return (
|
||||
<div
|
||||
class={cn(
|
||||
"border-2 border-transparent hover:border-dashed hover:border-current p-2 flex flex-col justify-between",
|
||||
"[&_h1]:text-2xl [&_h1]:font-bold [&_h1]:mt-4",
|
||||
props.class,
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<Image
|
||||
src={poster}
|
||||
alt={name}
|
||||
class="w-full transition-all object-cover"
|
||||
fetchpriority={fetchpriority}
|
||||
/>
|
||||
<h1>{name}</h1>
|
||||
{description && (
|
||||
<h4 class={"opacity-40 text-sm"}>
|
||||
{description.length > 140
|
||||
? `${description.slice(0, 140)}...`
|
||||
: description}
|
||||
</h4>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
class={cn(
|
||||
"mt-4 flex justify-between items-center",
|
||||
!hasEpisodes() && "w-full",
|
||||
)}
|
||||
>
|
||||
{hasEpisodes() && (
|
||||
<Button asLink variant="active" href={`/watch/${href}`}>
|
||||
Play
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
class={cn(!hasEpisodes() && "w-full")}
|
||||
onClick={async () => {
|
||||
|
||||
toast.success("Saved to My List");
|
||||
}}
|
||||
>
|
||||
<BookmarkIcon /> Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
import {
|
||||
AccessorWithLatest,
|
||||
createAsync,
|
||||
CustomResponse,
|
||||
redirect,
|
||||
revalidate,
|
||||
RouteDefinition,
|
||||
RouteSectionProps,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "@solidjs/router";
|
||||
import {
|
||||
createEffect,
|
||||
onCleanup,
|
||||
For,
|
||||
Loading,
|
||||
Errored
|
||||
} from "solid-js";
|
||||
import { api } from "~/lib/api";
|
||||
import { getLinkParams } from "~/utils/url";
|
||||
import { query } from "@solidjs/router";
|
||||
import { withBackoff } from "~/utils/backoff";
|
||||
import Artplayer from "artplayer";
|
||||
import Hls from "hls.js/dist/hls.light.js";
|
||||
import type { HlsConfig } from "hls.js";
|
||||
import Button from "~/components/button";
|
||||
|
||||
interface WatchArgs {
|
||||
t?: number;
|
||||
id: string;
|
||||
ep: number;
|
||||
num: number;
|
||||
lang?: "en" | "jp";
|
||||
}
|
||||
|
||||
interface WatchReturn {
|
||||
show: AniwatchInfo;
|
||||
episode: AniwatchEpisodeData;
|
||||
nextEpTiming: NextEpisodeTiming;
|
||||
id: string;
|
||||
epNum: number;
|
||||
ep: number;
|
||||
sources: AniwatchEpisodeSrc;
|
||||
}
|
||||
async function watchPageData({
|
||||
lang,
|
||||
num,
|
||||
ep,
|
||||
id,
|
||||
}: WatchArgs): Promise<WatchReturn | CustomResponse<never>> {
|
||||
const episode = await api.anime.episode_info.query(id);
|
||||
|
||||
if (episode.data.episodes.length === 0 || episode.data.totalEpisodes === 0) {
|
||||
return redirect("/error/No%20Dub%20Available");
|
||||
}
|
||||
|
||||
const epNum = num - 1;
|
||||
|
||||
const epId =
|
||||
episode.data?.episodes[epNum] ||
|
||||
episode.data?.episodes[epNum - 1] ||
|
||||
episode.data?.episodes[0];
|
||||
|
||||
const {
|
||||
params: { id: episodeId },
|
||||
search,
|
||||
} = getLinkParams(epId.episodeId);
|
||||
|
||||
if (!ep) {
|
||||
return redirect(`/watch/${episodeId}${search.toString({ num })}`);
|
||||
}
|
||||
|
||||
const server = await api.anime.episode_server.query({
|
||||
id,
|
||||
ep,
|
||||
});
|
||||
|
||||
if (server.data?.dub?.length === 0 && lang === "en") {
|
||||
return redirect("/error/No%20Dub%20Available");
|
||||
}
|
||||
|
||||
const serverName =
|
||||
lang === "en"
|
||||
? server.data?.dub[0].serverName
|
||||
: !server.data?.sub[0]
|
||||
? server.data?.raw[0].serverName
|
||||
: server.data?.sub[0].serverName;
|
||||
|
||||
const sources = await api.anime.episode_src.query({
|
||||
id,
|
||||
ep,
|
||||
server: serverName,
|
||||
category: lang === "en" ? "dub" : !server.data?.sub[0] ? "raw" : "sub",
|
||||
});
|
||||
if (sources.status !== 200 || sources.data.sources.length === 0) {
|
||||
return redirect(`/error/No%20Source%20Available`);
|
||||
}
|
||||
|
||||
if ((sources.status !== 200 || !sources.data.sources) && lang === "en") {
|
||||
return redirect("/error/No%20Dub%20Available");
|
||||
}
|
||||
|
||||
if (sources.data.sources.length === 0 && lang === "jp") {
|
||||
return redirect("/error/No%20Source%20Available");
|
||||
}
|
||||
|
||||
const show = (await api.anime.anime_info.query(id)) as AniwatchInfo;
|
||||
const nextEpTiming = await api.anime.next_episode_time.query(id);
|
||||
return { episode, nextEpTiming, id, epNum, ep, sources, show };
|
||||
}
|
||||
|
||||
const getWatchData = query(async (args: WatchArgs) => {
|
||||
const res = await withBackoff(async () => await watchPageData(args));
|
||||
if (res.success) {
|
||||
return res.data;
|
||||
} else throw res.error;
|
||||
}, "watchPage");
|
||||
|
||||
export const route = {
|
||||
async preload({ params, location }) {
|
||||
const { id } = params as { id: string };
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const ep = Number(searchParams.get("ep"));
|
||||
const lang = searchParams.get("lang") as WatchArgs["lang"];
|
||||
const t = Number(searchParams.get("t") ?? 0);
|
||||
const num = Number(searchParams.get("num") ?? 0);
|
||||
void watchPageData({
|
||||
ep,
|
||||
id,
|
||||
num,
|
||||
lang,
|
||||
t,
|
||||
});
|
||||
},
|
||||
} satisfies RouteDefinition;
|
||||
|
||||
export default function Watch({}: RouteSectionProps) {
|
||||
const params = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
const anime = createAsync(
|
||||
async () =>
|
||||
await getWatchData({
|
||||
id: params.id ?? "",
|
||||
ep: Number(searchParams.ep),
|
||||
num: Number(searchParams.num ?? 0),
|
||||
t: Number(searchParams.t ?? 0),
|
||||
lang: searchParams.lang,
|
||||
} as WatchArgs),
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Errored
|
||||
fallback={(error, reset) => (
|
||||
<div>
|
||||
<p>{error.message}</p>
|
||||
<button onClick={reset}>Try Again</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Loading fallback={<p>Loading video...</p>}>
|
||||
<Player anime={anime} />
|
||||
<Episodes anime={anime} />
|
||||
</Loading>
|
||||
</Errored>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Player({
|
||||
anime,
|
||||
}: {
|
||||
anime: AccessorWithLatest<WatchReturn | undefined>;
|
||||
}) {
|
||||
let containerRef!: HTMLDivElement;
|
||||
let videoRef!: HTMLVideoElement;
|
||||
let artRef!: Artplayer;
|
||||
let hls!: Hls;
|
||||
|
||||
class loader extends Hls.DefaultConfig.loader {
|
||||
constructor(config: HlsConfig) {
|
||||
super(config);
|
||||
|
||||
const baseLoad = this.load.bind(this);
|
||||
|
||||
this.load = (context, config, callbacks) => {
|
||||
const proxiedContext = {
|
||||
...context,
|
||||
url: getProxiedUrl(context.url),
|
||||
};
|
||||
baseLoad(proxiedContext, config, callbacks);
|
||||
};
|
||||
|
||||
function getProxiedUrl(originalUrl: string) {
|
||||
const proxies = [
|
||||
import.meta.env.VITE_PROXY_PREFIX,
|
||||
import.meta.env.VITE_PROXY_PREFIX_VAMANA,
|
||||
import.meta.env.VITE_PROXY_PREFIX_BALARAMA,
|
||||
];
|
||||
const prefix: string[] = proxies
|
||||
.filter((x) => x && x.trim() !== "")
|
||||
.filter((x) => x !== undefined);
|
||||
|
||||
const chance = Math.floor(Math.random() * prefix.length);
|
||||
const pick = prefix[chance];
|
||||
|
||||
const cleanUrl = originalUrl.replaceAll("//", "/");
|
||||
let hasProxyPrefix = false;
|
||||
prefix.forEach((t) => {
|
||||
if (cleanUrl.startsWith(t)) {
|
||||
hasProxyPrefix = true;
|
||||
}
|
||||
});
|
||||
return hasProxyPrefix ? originalUrl : `${pick}${cleanUrl}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const hlsConfig: Partial<HlsConfig> = {
|
||||
fragLoadingMaxRetry: 200,
|
||||
fragLoadingRetryDelay: 500,
|
||||
fragLoadingTimeOut: 30_000,
|
||||
fragLoadingMaxRetryTimeout: 1000,
|
||||
maxBufferLength: 60 * 30,
|
||||
maxMaxBufferLength: 60 * 30,
|
||||
maxBufferHole: 0.5,
|
||||
enableSoftwareAES: true,
|
||||
autoStartLoad: true,
|
||||
progressive: true,
|
||||
loader: loader,
|
||||
};
|
||||
|
||||
createEffect(() => {
|
||||
const nextEpUrl = () =>
|
||||
anime()?.episode.data?.episodes[(anime()?.epNum ?? 0) + 1]?.episodeId;
|
||||
const prevEpUrl = () =>
|
||||
anime()?.episode.data?.episodes[(anime()?.epNum ?? 0) - 1]?.episodeId;
|
||||
// if (artRef) {
|
||||
// artRef.destroy();
|
||||
// }
|
||||
if (hls) {
|
||||
console.log("updating");
|
||||
hls.loadSource(anime()?.sources.data.sources[0].url ?? "");
|
||||
// hls.destroy();
|
||||
return;
|
||||
}
|
||||
console.log("entering");
|
||||
|
||||
const sources = anime()?.sources.data;
|
||||
if (!sources) {
|
||||
revalidate(getWatchData.key);
|
||||
return;
|
||||
}
|
||||
|
||||
const voiceTracks = sources?.tracks.filter(
|
||||
(item) => item.lang !== "thumbnails",
|
||||
);
|
||||
const src = sources.sources[0].url;
|
||||
|
||||
artRef = new Artplayer({
|
||||
url: src,
|
||||
container: containerRef,
|
||||
setting: true,
|
||||
fullscreen: true,
|
||||
fullscreenWeb: true,
|
||||
playbackRate: true,
|
||||
autoPlayback: true,
|
||||
screenshot: true,
|
||||
gesture: true,
|
||||
backdrop: true,
|
||||
hotkey: true,
|
||||
volume: 0.5,
|
||||
muted: false,
|
||||
autoplay: true,
|
||||
moreVideoAttr: {
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
plugins: [
|
||||
// artplayerPluginChapter({
|
||||
// chapters: [
|
||||
// {
|
||||
// title: "Opening Song",
|
||||
// start: intro.start,
|
||||
// end: intro.end,
|
||||
// },
|
||||
// {
|
||||
// title: "Ending Song",
|
||||
// start: outro.start,
|
||||
// end: outro.end,
|
||||
// },
|
||||
// ],
|
||||
// }),
|
||||
// artplayerPluginHlsControl({
|
||||
// quality: {
|
||||
// control: true,
|
||||
// setting: true,
|
||||
// getName: (level: { height: string }) => level.height,
|
||||
// title: "Quality",
|
||||
// auto: "Auto",
|
||||
// },
|
||||
// audio: {
|
||||
// control: true,
|
||||
// setting: true,
|
||||
// getName: (track: { name: string }) => track.name,
|
||||
// title: "Audio",
|
||||
// auto: "Auto",
|
||||
// },
|
||||
// }),
|
||||
],
|
||||
settings: [
|
||||
{
|
||||
width: 200,
|
||||
html: "Subtitle",
|
||||
tooltip: "Subtitle",
|
||||
selector: [
|
||||
{
|
||||
html: "Display",
|
||||
tooltip: "Show",
|
||||
switch: true,
|
||||
onSwitch: (item) => {
|
||||
item.tooltip = item.switch ? "Hide" : "Show";
|
||||
if (artRef) {
|
||||
artRef.subtitle.show = !item.switch;
|
||||
}
|
||||
return !item.switch;
|
||||
},
|
||||
},
|
||||
...(voiceTracks ?? []).map((sub) => {
|
||||
return {
|
||||
html: sub.lang,
|
||||
url: sub.url,
|
||||
default: sub.lang === "English",
|
||||
};
|
||||
}),
|
||||
],
|
||||
onSelect: (item) => {
|
||||
if (!artRef) return;
|
||||
artRef.subtitle.switch(item.url, {
|
||||
name: item.html,
|
||||
});
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
name: "prev",
|
||||
html: "<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22' fill='none' stroke='currentColor' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='lucide lucide-skip-back-icon lucide-skip-back'><path d='M17.971 4.285A2 2 0 0 1 21 6v12a2 2 0 0 1-3.029 1.715l-9.997-5.998a2 2 0 0 1-.003-3.432z'/><path d='M3 20V4'/></svg>",
|
||||
tooltip: "Previous",
|
||||
position: "left",
|
||||
disable: !prevEpUrl(),
|
||||
click: () => {
|
||||
if (!artRef) return;
|
||||
if (!prevEpUrl()) return;
|
||||
artRef.notice.show = "Playing previous episode";
|
||||
// playPrevious();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "next",
|
||||
html: "<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22' fill='none' stroke=Color' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' class='lucide lucide-skip-forward-icon lucide-skip-forward'><path d='M21 4v16'/><path d='M6.029 4.285A2 2 0 0 0 3 6v12a2 2 0 0 0 3.029 1.715l9.997-5.998a2 2 0 0 0 .003-3.432z'/></svg>",
|
||||
tooltip: "Next",
|
||||
position: "right",
|
||||
disable: !nextEpUrl(),
|
||||
click: () => {
|
||||
if (!artRef) return;
|
||||
if (!nextEpUrl()) return;
|
||||
artRef.notice.show = "Playing next episode";
|
||||
// playNext();
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "speed",
|
||||
position: "right",
|
||||
html: "Speed",
|
||||
selector: [
|
||||
{
|
||||
html: "1x",
|
||||
},
|
||||
{
|
||||
html: "2x",
|
||||
},
|
||||
],
|
||||
onSelect: (item) => {
|
||||
if (!artRef) return;
|
||||
const speed = Number((item.html as string).charAt(0));
|
||||
artRef.storage.set("speed", speed);
|
||||
// setPlayerOpts({ ...playerOpts, speed });
|
||||
artRef.video.playbackRate = speed;
|
||||
artRef.notice.show = `Speed set to ${speed}x`;
|
||||
return item.html;
|
||||
},
|
||||
},
|
||||
],
|
||||
subtitle: {
|
||||
url:
|
||||
(voiceTracks ?? []).filter((sub) => sub.lang === "English")[0]?.url ??
|
||||
"",
|
||||
escape: true,
|
||||
name: "English",
|
||||
onVttLoad: (vtt) => {
|
||||
return vtt.replace(/<[^>]+>/g, "");
|
||||
},
|
||||
type: "vtt",
|
||||
encoding: "utf-8",
|
||||
style: {
|
||||
fontWeight: "600",
|
||||
fontSize: "28px",
|
||||
},
|
||||
},
|
||||
customType: {
|
||||
m3u8: function playM3u8(video, url, art) {
|
||||
if (Hls.isSupported()) {
|
||||
if (!hls) {
|
||||
hls = new Hls(hlsConfig);
|
||||
}
|
||||
hls?.loadSource(url); // this load for the initial video
|
||||
hls?.attachMedia(video);
|
||||
(art as any).hls = hls;
|
||||
|
||||
// if (time) {
|
||||
// videoTime = time;
|
||||
// }
|
||||
const speed = Number(art.storage.get("speed")) ?? 1;
|
||||
if (speed) {
|
||||
video.playbackRate = speed;
|
||||
}
|
||||
videoRef = video;
|
||||
art.on("destroy", () => hls?.destroy());
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
} else {
|
||||
art.notice.show = "Unsupported playback format: m3u8";
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},(pop)=>{
|
||||
|
||||
});
|
||||
|
||||
// onCleanup(() => {
|
||||
// console.log("leaving");
|
||||
// if (artRef) {
|
||||
// artRef.destroy(false);
|
||||
// }
|
||||
// if (hls) {
|
||||
// hls?.destroy();
|
||||
// }
|
||||
// });
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabindex={1}
|
||||
class="mt-4 w-full h-[300px] sm:h-[350px] md:h-[450px] lg:h-[550px] xl:h-[600px] focus:outline-0 focus-within:outline-0"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Episodes({
|
||||
anime,
|
||||
}: {
|
||||
anime: AccessorWithLatest<WatchReturn | undefined>;
|
||||
}) {
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// const naviagte = useNavigate();
|
||||
return (
|
||||
<div class="grid grid-cols-2 mt-4">
|
||||
<For each={anime()?.episode.data.episodes}>
|
||||
{(episode, i) => (
|
||||
<Button
|
||||
variant="outline"
|
||||
asLink
|
||||
href={
|
||||
`/watch/${episode().episodeId}&num=${episode().number}&lang=${searchParams.lang ?? "jp"}`
|
||||
}
|
||||
onClick={(e) => {
|
||||
// e.preventDefault();
|
||||
// naviagte(
|
||||
// `/watch/${episode.episodeId}&num=${episode.number}&lang=${searchParams.lang ?? "jp"}`,
|
||||
// );
|
||||
revalidate(getWatchData.key);
|
||||
}}
|
||||
>
|
||||
{i() + 1}. {episode().title}
|
||||
</Button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnimeInfo({
|
||||
anime,
|
||||
}: {
|
||||
anime: AccessorWithLatest<WatchReturn | undefined>;
|
||||
}){
|
||||
return(
|
||||
<div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
|
||||
export async function GET(req: APIEvent) {
|
||||
|
||||
const reqUrl = req.params.all
|
||||
// const reqUrl: string[] = req.params.all.split("/").slice(3); // removes http : //
|
||||
|
||||
const completeUrl = reqUrl
|
||||
.split("/")
|
||||
.map((part) => (part === "https:" ? part + "//" : part + "/"))
|
||||
.join("");
|
||||
|
||||
const sanitizedUrl = new URL(decodeURIComponent(completeUrl));
|
||||
|
||||
if(sanitizedUrl.host === "thunderstrike77.online"){
|
||||
sanitizedUrl.host = "haildrop77.pro"
|
||||
}
|
||||
|
||||
const res = await fetch((sanitizedUrl), {
|
||||
cache: "no-store",
|
||||
priority: "high",
|
||||
redirect: "follow",
|
||||
keepalive: true,
|
||||
headers: {
|
||||
...req.request.headers,
|
||||
Referer: "https://megacloud.club/",
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return Response.json({
|
||||
error: res.status,
|
||||
message: res.statusText,
|
||||
});
|
||||
}
|
||||
|
||||
const reader: ReadableStreamDefaultReader | null =
|
||||
res.body?.getReader() || null;
|
||||
|
||||
function iteratorToStream(
|
||||
iterator: ReadableStreamDefaultReader
|
||||
): ReadableStream {
|
||||
return new ReadableStream({
|
||||
async pull(controller) {
|
||||
const { value, done } = await iterator.read();
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
} else if (value) {
|
||||
controller.enqueue(value);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!reader) {
|
||||
return new Response("No body in response", { status: 500 });
|
||||
}
|
||||
|
||||
const stream = iteratorToStream(reader);
|
||||
|
||||
return new Response(stream, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type":
|
||||
res.headers.get("Content-Type") || "application/octet-stream",
|
||||
"Cache-Control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export async function GET (){
|
||||
return Response.json({oallk:true})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export async function GET (){
|
||||
return Response.json({rest:true})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export async function GET (){
|
||||
return Response.json({hello:true})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export async function GET (){
|
||||
return Response.json({ok:true})
|
||||
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { APIEvent } from "@solidjs/start/server";
|
||||
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
|
||||
import { appRouter } from "~/trpc/api/root";
|
||||
|
||||
const handler = (event: APIEvent) =>
|
||||
// adapts tRPC to fetch API style requests
|
||||
fetchRequestHandler({
|
||||
// the endpoint handling the requests
|
||||
endpoint: "/api/trpc",
|
||||
// the request object
|
||||
req: event.request,
|
||||
// the router for handling the requests
|
||||
router: appRouter,
|
||||
// any arbitrary data that should be available to all actions
|
||||
createContext: () => event,
|
||||
onError(opts) {
|
||||
const { error, type, path, input, ctx, req } = opts;
|
||||
console.error('Error:', error);
|
||||
if (error.code === 'INTERNAL_SERVER_ERROR') {
|
||||
// send to bug reporting
|
||||
}
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
export const GET = handler;
|
||||
export const POST = handler;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { anime, tmdb } from "./routers/anime";
|
||||
import { createTRPCRouter } from "./utils";
|
||||
|
||||
export const appRouter = createTRPCRouter({
|
||||
anime,
|
||||
tmdb
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
@@ -0,0 +1,302 @@
|
||||
import { TRPCError, type TRPCRouterRecord } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
import { cached } from "~/lib/cache";
|
||||
import { publicProcedure } from "../utils";
|
||||
|
||||
const baseUrl = "https://api.themoviedb.org/3";
|
||||
const key = process.env.TMDB_KEY;
|
||||
const anime_url = process.env.ANIWATCH_API;
|
||||
|
||||
export const tmdb = {
|
||||
home: publicProcedure.query(async () => {
|
||||
return cached("tmdb:home", async () => {
|
||||
const response = await fetch(`${baseUrl}/movie/popular?api_key=${key}`);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
popularMovies: publicProcedure.query(async () => {
|
||||
return cached("tmdb:popular", async () => {
|
||||
const response = await fetch(`${baseUrl}/movie/popular?api_key=${key}`);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
trendingMovies: publicProcedure.query(async () => {
|
||||
return cached("tmdb:trending:week", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/trending/movie/week?api_key=${key}®ion=IN&with_original_language=hi`,
|
||||
);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
upcomingMovies: publicProcedure.query(async () => {
|
||||
return cached("tmdb:upcoming", async () => {
|
||||
const response = await fetch(`${baseUrl}/movie/upcoming?api_key=${key}`);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
topRatedMovies: publicProcedure.query(async () => {
|
||||
return cached("tmdb:top-rated", async () => {
|
||||
const response = await fetch(`${baseUrl}/movie/top_rated?api_key=${key}`);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
nowPlayingMovies: publicProcedure.query(async () => {
|
||||
return cached("tmdb:now-playing", async () => {
|
||||
const response = await fetch(`${baseUrl}/movie/now_playing?api_key=${key}`);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
topRatedTvShows: publicProcedure.query(async () => {
|
||||
return cached("tmdb:tv:top-rated", async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/tv/top_rated?api_key=${key}®ion=US`,
|
||||
);
|
||||
return (await response.json()) as TMDBMovie;
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
query: z.string(),
|
||||
page: z.number().optional().default(1),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return cached(`tmdb:search:${input.query}:${input.page}`, async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/search/multi?api_key=${key}&query=${input.query}`,
|
||||
);
|
||||
return (await response.json()) as TMDBMultiSearch;
|
||||
});
|
||||
}),
|
||||
info: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
type: z.enum(["movie", "tv"]),
|
||||
id: z.number(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return cached(`tmdb:info:${input.type}:${input.id}`, async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/${input.type}/${input.id}?api_key=${key}`,
|
||||
);
|
||||
return (await response.json()) as MovieResults;
|
||||
});
|
||||
}),
|
||||
season_info: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
season: z.number(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
return cached(`tmdb:season:${input.id}:${input.season}`, async () => {
|
||||
const response = await fetch(
|
||||
`${baseUrl}/tv/${input.id}/season/${input.season}?api_key=${key}`,
|
||||
);
|
||||
return (await response.json()) as TMDBEpisodesInfo;
|
||||
});
|
||||
}),
|
||||
} satisfies TRPCRouterRecord;
|
||||
|
||||
export const anime = {
|
||||
home: publicProcedure.query(async () => {
|
||||
return cached("anime:home", async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/home`);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime home",
|
||||
})
|
||||
}
|
||||
const data = (await response.json()) as AniwatchHome;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime home",
|
||||
})
|
||||
}
|
||||
return data;
|
||||
},);
|
||||
}),
|
||||
anime_info: publicProcedure
|
||||
.input(z.string())
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:info:${input}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/anime/${input}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime info",
|
||||
})
|
||||
}
|
||||
const data = await response.json() as AniwatchInfo;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime info",
|
||||
})
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
category: publicProcedure
|
||||
.input(z.object({
|
||||
type: z.string(),
|
||||
pageToFetch: z.number(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:category:${input.type}:${input.pageToFetch}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/category/${input.type}?page=${input.pageToFetch}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime category",
|
||||
});
|
||||
}
|
||||
const data = (await response.json()) as AniwatchSearch;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch anime category",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
episode_info: publicProcedure
|
||||
.input(z.string())
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:episode-info:${input}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/anime/${input}/episodes`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode info",
|
||||
});
|
||||
}
|
||||
const data = await response.json() as AniwatchEpisodeData;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode info",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
episode_server: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
ep: z.number(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:episode-server:${input.id}:${input.ep}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/episode/servers?animeEpisodeId=${input.id}?ep=${input.ep}`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode server info",
|
||||
});
|
||||
}
|
||||
const data = await response.json() as AniwatchServer;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode server info",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
next_episode_time: publicProcedure
|
||||
.input(z.string())
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:next-ep:${input}`, async () => {
|
||||
const response = await fetch(`${anime_url}/api/v2/hianime/anime/${input}/next-episode-schedule`);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch next episode time",
|
||||
});
|
||||
}
|
||||
const data = await response.json() as NextEpisodeTiming;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch next episode time",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
episode_src: publicProcedure
|
||||
.input(z.object({
|
||||
id: z.string(),
|
||||
ep: z.number(),
|
||||
server: z.string(),
|
||||
category: z.string(),
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:episode-src:${input.id}:${input.ep}:${input.server}:${input.category}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url
|
||||
}/api/v2/hianime/episode/sources?animeEpisodeId=${input.id}?ep=${input.ep}&server=${input.server ? input.server : "vidstreaming"
|
||||
}&category=${input.category}`,
|
||||
{ cache: "no-cache" }
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode source",
|
||||
});
|
||||
}
|
||||
const data = await response.json() as AniwatchEpisodeSrc;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to fetch episode source",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
}),
|
||||
search: publicProcedure
|
||||
.input(z.object({
|
||||
query: z.string(),
|
||||
page: z.number().optional()
|
||||
}))
|
||||
.query(async ({ input }) => {
|
||||
return cached(`anime:search:${input.query}:${input.page ?? 1}`, async () => {
|
||||
const response = await fetch(
|
||||
`${anime_url}/api/v2/hianime/search?q=${input.query}&page=${input.page ?? 1}`);
|
||||
if (!response.ok) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to search anime",
|
||||
});
|
||||
}
|
||||
const data = await response.json() as AniwatchSearch;
|
||||
if (data.status !== 200) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to search anime",
|
||||
});
|
||||
}
|
||||
return data;
|
||||
});
|
||||
})
|
||||
} satisfies TRPCRouterRecord;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { initTRPC } from "@trpc/server";
|
||||
|
||||
export const t = initTRPC.create();
|
||||
|
||||
export const createTRPCRouter = t.router;
|
||||
export const publicProcedure = t.procedure;
|
||||
Vendored
+309
@@ -0,0 +1,309 @@
|
||||
interface AniwatchHome {
|
||||
status: number,
|
||||
data: {
|
||||
spotlightAnimes: SpotlightAnimes[];
|
||||
trendingAnimes: TrendingAnimes[];
|
||||
latestEpisodeAnimes: LatestEpisodeAnimes[];
|
||||
topUpcomingAnimes: TopUpcomingAnimes[];
|
||||
top10Animes: Top10Animes;
|
||||
topAiringAnimes: TopAiringAnimes[];
|
||||
genres: string[];
|
||||
};
|
||||
}
|
||||
|
||||
interface TopAiringAnimes {
|
||||
id: string;
|
||||
name: string;
|
||||
jname?: string;
|
||||
type?: string;
|
||||
description: string;
|
||||
poster: string;
|
||||
otherInfo: string[];
|
||||
}
|
||||
|
||||
interface Top10Animes {
|
||||
today: Top10AnimesResult[];
|
||||
week: Top10AnimesResult[];
|
||||
month: Top10AnimesResult[];
|
||||
}
|
||||
|
||||
interface Top10AnimesResult {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
poster: string;
|
||||
episodes: { sub: number; dub: number };
|
||||
}
|
||||
|
||||
interface TopUpcomingAnimes {
|
||||
id: string;
|
||||
name: string;
|
||||
duration: string;
|
||||
poster: string;
|
||||
type: string;
|
||||
rating: string | null;
|
||||
episodes: { sub: number; dub: number };
|
||||
}
|
||||
|
||||
interface LatestEpisodeAnimes {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
poster: string;
|
||||
type: string;
|
||||
rating: string | null;
|
||||
episodes: { sub: number; dub: number };
|
||||
}
|
||||
|
||||
interface NextEpisodeTiming {
|
||||
status: number,
|
||||
data: {
|
||||
airingISOTimestamp: string | null;
|
||||
airingTimestamp: number | null;
|
||||
secondsUntilAiring: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface TrendingAnimes {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
poster: string;
|
||||
}
|
||||
|
||||
interface SpotlightAnimes {
|
||||
rank: number;
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
poster: string;
|
||||
jname: string;
|
||||
episodes: { sub: number; dub: number };
|
||||
otherInfo: [string, string, string, string];
|
||||
}
|
||||
|
||||
interface AniwatchInfo {
|
||||
status: number;
|
||||
data: {
|
||||
anime: {
|
||||
info: {
|
||||
id: string;
|
||||
anilistId: number;
|
||||
malId: number;
|
||||
name: string;
|
||||
poster: string;
|
||||
description: string;
|
||||
stats: {
|
||||
rating: string;
|
||||
quality: string;
|
||||
episodes: { sub: number; dub: number };
|
||||
type: string;
|
||||
duration: string;
|
||||
promotionalVideos: {
|
||||
title: string | undefined,
|
||||
source: string | undefined,
|
||||
thumbnail: string | undefined
|
||||
}[],
|
||||
characterVoiceActor: {
|
||||
character: {
|
||||
id: string,
|
||||
poster: string,
|
||||
name: string,
|
||||
cast: string
|
||||
},
|
||||
voiceActor: {
|
||||
id: string,
|
||||
poster: string,
|
||||
name: string,
|
||||
cast: string
|
||||
}
|
||||
}[]
|
||||
};
|
||||
};
|
||||
moreInfo: {
|
||||
japanese: string;
|
||||
synonyms: string;
|
||||
aired: string;
|
||||
premiered: string;
|
||||
duration: string;
|
||||
status: "Finished Airing" | "Currently Airing" | ({} & string);
|
||||
malscore: string;
|
||||
genres: string[];
|
||||
studios: string;
|
||||
producers: string[];
|
||||
};
|
||||
};
|
||||
seasons: {
|
||||
id: string;
|
||||
name: string;
|
||||
title: string;
|
||||
poster: string;
|
||||
isCurrent: boolean;
|
||||
}[];
|
||||
mostPopularAnimes: {
|
||||
id: string;
|
||||
name: string;
|
||||
poster: string;
|
||||
jname: string;
|
||||
episodes: {
|
||||
sub: number;
|
||||
dub: number;
|
||||
};
|
||||
type: string;
|
||||
}[];
|
||||
relatedAnimes: {
|
||||
id: string;
|
||||
name: string;
|
||||
poster: string;
|
||||
jname: string;
|
||||
episodes: {
|
||||
sub: number;
|
||||
dub: number;
|
||||
};
|
||||
type: '';
|
||||
}[];
|
||||
recommendedAnimes: {
|
||||
id: string;
|
||||
name: string;
|
||||
jname?: string;
|
||||
poster: string;
|
||||
duration: string;
|
||||
type: string;
|
||||
rating: string;
|
||||
episodes: {
|
||||
sub: number;
|
||||
dub: number;
|
||||
};
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AniwatchEpisodeData {
|
||||
status: number;
|
||||
data: {
|
||||
totalEpisodes: number;
|
||||
episodes: {
|
||||
title: string;
|
||||
episodeId: string;
|
||||
number: string;
|
||||
isFiller: boolean;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
interface AniwatchSearch {
|
||||
status: number,
|
||||
data: {
|
||||
animes: Anime[];
|
||||
mostPopularAnimes: {
|
||||
id: string;
|
||||
name: string;
|
||||
poster: string;
|
||||
jname: string;
|
||||
episodes: { sub: number; dub: number };
|
||||
type: string;
|
||||
}[];
|
||||
currentPage: number;
|
||||
hasNextPage: boolean;
|
||||
totalPages: number;
|
||||
searchQuery: string;
|
||||
searchFilters: object;
|
||||
};
|
||||
}
|
||||
|
||||
interface Anime {
|
||||
id: string;
|
||||
name: string;
|
||||
poster: string;
|
||||
duration: string;
|
||||
type: string;
|
||||
jname?: string;
|
||||
rating: string;
|
||||
episodes: { sub: number; dub: number };
|
||||
}
|
||||
|
||||
interface AniwatchEpisodeSrc {
|
||||
status: number,
|
||||
data: {
|
||||
headers: {
|
||||
Referer: string,
|
||||
"User-Agent": string,
|
||||
},
|
||||
tracks: { url: string; lang: string; default?: boolean }[];
|
||||
intro: { start: number; end: number };
|
||||
outro: { start: number; end: number };
|
||||
sources: { url: string; type: string }[];
|
||||
anilistID: number | null,
|
||||
malID: number | null
|
||||
};
|
||||
}
|
||||
|
||||
interface AniwatchGenre {
|
||||
status: number,
|
||||
data: {
|
||||
genreName: string;
|
||||
animes: Anime[];
|
||||
genres: string[];
|
||||
topAiringAnimes: TopAiringAnimes[];
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
currentPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AniwatchStudio {
|
||||
producerName: string;
|
||||
animes: Anime[];
|
||||
genres: string[];
|
||||
topAiringAnimes: TopAiringAnimes[];
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
currentPage: number;
|
||||
}
|
||||
|
||||
interface AniwatchCategories {
|
||||
status: number,
|
||||
data: {
|
||||
genres: string[];
|
||||
animes: Anime[];
|
||||
top10Animes: Top10Animes;
|
||||
category: string;
|
||||
totalPages: number;
|
||||
hasNextPage: boolean;
|
||||
currentPage: number;
|
||||
};
|
||||
}
|
||||
|
||||
type AniwatchCategoriesName =
|
||||
| "most-favorite"
|
||||
| "most-popular"
|
||||
| "subbed-anime"
|
||||
| "dubbed-anime"
|
||||
| "recently-updated"
|
||||
| "recently-added"
|
||||
| "top-upcoming"
|
||||
| "top-airing"
|
||||
| "movie"
|
||||
| "special"
|
||||
| "ova"
|
||||
| "ona"
|
||||
| "tv"
|
||||
| "completed";
|
||||
|
||||
interface AniwatchServer {
|
||||
status: number;
|
||||
data: {
|
||||
dub: {
|
||||
serverName: string, serverId: number
|
||||
}[],
|
||||
sub: {
|
||||
serverName: string, serverId: number
|
||||
}[]
|
||||
raw: {
|
||||
serverName: string, serverId: number
|
||||
}[]
|
||||
episodeId: string,
|
||||
episodeNo: number
|
||||
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_USER_ID: string;
|
||||
readonly VITE_PUBLIC_ENDPOINT: string;
|
||||
readonly APP_URL:string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
readonly DB_URL: string
|
||||
readonly DB_PASSWORD: string
|
||||
}
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
declare module "hls.js/dist/hls.light.js" {
|
||||
import Hls from "hls.js";
|
||||
export default Hls;
|
||||
}
|
||||
Vendored
+211
@@ -0,0 +1,211 @@
|
||||
type MovieResults = {
|
||||
adult: boolean
|
||||
genre_ids: number[]
|
||||
backdrop_path: string
|
||||
id: number
|
||||
original_language: string
|
||||
original_title: string
|
||||
overview: string
|
||||
popularity: number
|
||||
poster_path: string
|
||||
release_date: string
|
||||
title: string
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
first_air_date: string
|
||||
title_english: string
|
||||
synopsis: string
|
||||
imdb_id: number
|
||||
name: string
|
||||
image: string
|
||||
known_for?: string[]
|
||||
profile_path?: string
|
||||
media_type: "movie" | "tv" | "person" | "collection"
|
||||
seasons: TMDBMultiSeason[]
|
||||
}
|
||||
|
||||
type TMDBMovie = {
|
||||
page: number
|
||||
results: MovieResults[]
|
||||
total_pages: number
|
||||
total_results: number
|
||||
}
|
||||
|
||||
type TMDBMultiSearch = {
|
||||
results: TMDBMultiResult[]
|
||||
total_pages: number
|
||||
total_results: number
|
||||
page: number
|
||||
}
|
||||
|
||||
type TMDBMultiResult = {
|
||||
id: number
|
||||
genre_ids: string[]
|
||||
video: boolean
|
||||
adult: boolean
|
||||
backdrop_path: string
|
||||
original_language: string
|
||||
original_title: string
|
||||
overview: string
|
||||
popularity: number
|
||||
poster_path: string
|
||||
release_date: string
|
||||
title: string
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
first_air_date: string
|
||||
title_english: string
|
||||
synopsis: string
|
||||
imdb_id: number
|
||||
name: string
|
||||
image: string
|
||||
media_type: "movie" | "tv" | "person" | "collection" | & (string)
|
||||
seasons: TMDBMultiSeason[]
|
||||
}
|
||||
|
||||
type TMDBMultiSeason = {
|
||||
air_date: string
|
||||
episode_count: number
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
poster_path: string
|
||||
season_number: string
|
||||
vote_average: number
|
||||
}
|
||||
|
||||
type TMDBMovieInfo = {
|
||||
adult: boolean
|
||||
backdrop_path: string
|
||||
belongs_to_collection: {
|
||||
id: number
|
||||
name: string
|
||||
poster_path: string
|
||||
backdrop_path: string
|
||||
}
|
||||
budget: number
|
||||
genres: { id: number; name: string }[]
|
||||
homepage: string
|
||||
id: number
|
||||
imdb_id: number
|
||||
origin_country: string[]
|
||||
original_language: string
|
||||
original_title: string
|
||||
overview: string
|
||||
popularity: number
|
||||
poster_path: string
|
||||
production_companies: {
|
||||
id: number
|
||||
logo_path: string | null
|
||||
name: string
|
||||
origin_country: string
|
||||
}[]
|
||||
production_countries: { iso_3166_1: string; name: string }[]
|
||||
release_date: string
|
||||
revenue: number
|
||||
runtime: number
|
||||
spoken_languages: { english_name: string; iso_639_1: string; name: string }[]
|
||||
status: string
|
||||
tagline: string
|
||||
title: string
|
||||
video: boolean
|
||||
vote_average: boolean
|
||||
vote_count: boolean
|
||||
}
|
||||
|
||||
type TMDBTvInfo = {
|
||||
adult: boolean
|
||||
title: string
|
||||
synopsis?: string
|
||||
overview?: string
|
||||
media_type?: string
|
||||
backdrop_path: string
|
||||
created_by: []
|
||||
episode_run_time: number[]
|
||||
first_air_date: string
|
||||
genres: { id: string; name: string }[]
|
||||
homepage: string
|
||||
id: number
|
||||
in_production: boolean
|
||||
languages: string[]
|
||||
last_air_date: string
|
||||
last_episode_to_air: {
|
||||
id: number
|
||||
overview: string
|
||||
name: string
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
air_date: string
|
||||
episode_number: number
|
||||
episode_type: string
|
||||
production_code: ""
|
||||
runtime: number | string
|
||||
season_number: number
|
||||
show_id: number
|
||||
still_path: string
|
||||
}
|
||||
name: string
|
||||
next_episode_to_air: null
|
||||
networks: []
|
||||
number_of_episodes: number
|
||||
number_of_seasons: number
|
||||
origin_country: string[]
|
||||
original_language: string
|
||||
original_name: string
|
||||
popularity: 85.99
|
||||
poster_path: string
|
||||
production_companies: {
|
||||
id: number
|
||||
logo_path: string
|
||||
name: string
|
||||
origin_country: string
|
||||
}[]
|
||||
production_countries: { iso_3166_1: string; name: string }[]
|
||||
seasons: {
|
||||
air_date: string
|
||||
episode_count: number
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
poster_path: string
|
||||
season_number: number
|
||||
vote_average: number
|
||||
}[]
|
||||
spoken_languages: { english_name: string; iso_639_1: string; name: string }[]
|
||||
status: string
|
||||
tagline: string
|
||||
type: string
|
||||
vote_average: string
|
||||
vote_count: number
|
||||
}
|
||||
|
||||
type TMDBEpisodesInfo = {
|
||||
air_date: string
|
||||
episodes: TMDBEpisodeResult[]
|
||||
name: string
|
||||
overview: string
|
||||
id: number
|
||||
poster_path?: string
|
||||
season_number: number
|
||||
vote_average: number
|
||||
}
|
||||
|
||||
type TMDBEpisodeResult = {
|
||||
air_date: string
|
||||
season: number | string
|
||||
episode: number | string
|
||||
episode_number: string
|
||||
episode_type: string
|
||||
id: number
|
||||
name: string
|
||||
overview: string
|
||||
production_code: string
|
||||
runtime: number
|
||||
season_number: number
|
||||
show_id: number
|
||||
still_path: string
|
||||
vote_average: number
|
||||
vote_count: number
|
||||
crew: []
|
||||
guest_stars: []
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
export interface BackoffOptions {
|
||||
shouldNotThrow?: boolean;
|
||||
maxRetries?: number;
|
||||
initialDelay?: number;
|
||||
maxDelay?: number;
|
||||
factor?: number;
|
||||
}
|
||||
|
||||
export type BackoffResult<T> =
|
||||
| { success: true; data: T; attempts: number }
|
||||
| { success: false; error: Error; attempts: number };
|
||||
|
||||
export async function withBackoff<T>(
|
||||
fn: () => Promise<T>,
|
||||
options: BackoffOptions = {},
|
||||
): Promise<BackoffResult<T>> {
|
||||
const {
|
||||
maxRetries = 5,
|
||||
initialDelay = 1000,
|
||||
maxDelay = 30000,
|
||||
factor = 2,
|
||||
shouldNotThrow = false
|
||||
} = options;
|
||||
|
||||
let lastError!: Error;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const data = await fn();
|
||||
return { success: true, data, attempts: attempt + 1 };
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
|
||||
if (attempt === maxRetries - 1) {
|
||||
return { success: false, error: lastError, attempts: attempt + 1 };
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
initialDelay * Math.pow(factor, attempt),
|
||||
maxDelay,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
if(!shouldNotThrow) throw lastError;
|
||||
|
||||
return { success: false, error: lastError, attempts: maxRetries };
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export const shouldCacheLonger = (src: AniwatchInfo, key: string) => {
|
||||
|
||||
const { moreInfo, info } = src.data.anime;
|
||||
|
||||
if (moreInfo.status === "Currently Airing") return false
|
||||
|
||||
const endedAt = moreInfo.aired.split(" to ")[1]; // ? if anime hasn't ended
|
||||
|
||||
if (!endedAt || endedAt === "?") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const endedAtDate = new Date(endedAt);
|
||||
|
||||
// check if older than 6 months
|
||||
const isOld = endedAtDate.getTime() < Date.now() - 6 * 30 * 24 * 60 * 60 * 1000;
|
||||
const isVeryOld = endedAtDate.getTime() < Date.now() - 24 * 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if (
|
||||
isOld &&
|
||||
moreInfo.status === "Finished Airing" &&
|
||||
info.stats.episodes.sub === info.stats.episodes.dub
|
||||
) {
|
||||
console.log(`[CACHE] ${key} cached longer has dub, sub and finished airing`);
|
||||
return true
|
||||
};
|
||||
|
||||
if (
|
||||
isOld &&
|
||||
isVeryOld &&
|
||||
moreInfo.status === "Finished Airing" &&
|
||||
info.stats.episodes.dub === 0 &&
|
||||
info.stats.episodes.sub
|
||||
) {
|
||||
console.log(`[CACHE] ${key} cached longer has sub and finished airing 2 years ago`);
|
||||
return true
|
||||
};
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
type AnyRecord = Record<string, number | boolean | null | undefined | string>;
|
||||
|
||||
interface SearchParams {
|
||||
ep?: number;
|
||||
lang?: "jp" | "en";
|
||||
toString: (args: AnyRecord) => string;
|
||||
}
|
||||
|
||||
export function createQueryString(
|
||||
options: Record<string, string | number | boolean | undefined | null>,
|
||||
args: AnyRecord
|
||||
): string {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(options).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
});
|
||||
Object.entries(args)?.forEach(([key, value]) => {
|
||||
params.append(key, String(value));
|
||||
});
|
||||
const queryString = params.toString();
|
||||
return queryString ? `?${queryString}` : "";
|
||||
}
|
||||
|
||||
export function getLinkParams(id: string): {
|
||||
params: { id: string };
|
||||
search: SearchParams;
|
||||
} {
|
||||
if (!id.includes("?")) return { params: { id: id }, search: {} };
|
||||
|
||||
const items = {
|
||||
id: id?.split("?")[0],
|
||||
ep: Number(id.split("?")[1].split("=")[1]) || 1,
|
||||
lang: (id.split("?")[1].split("=")[2] as SearchParams["lang"]) || "jp",
|
||||
};
|
||||
return {
|
||||
params: {
|
||||
id: items.id,
|
||||
},
|
||||
search: {
|
||||
ep: items.ep,
|
||||
lang: items.lang,
|
||||
toString(opt) {
|
||||
return createQueryString({ ep: this.ep, lang: this.lang }, opt);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const getBaseUrl = () => {
|
||||
const app_url = import.meta.env.APP_URL;
|
||||
if (app_url) return app_url;
|
||||
if (typeof window !== "undefined") return "";
|
||||
return `http://localhost:${import.meta.env.PORT ?? 3000}`;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"types": [
|
||||
"@solidjs/start/env"
|
||||
],
|
||||
"isolatedModules": true,
|
||||
"paths": {
|
||||
"~/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { solidStart } from "@solidjs/start/config";
|
||||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [
|
||||
// solid(),
|
||||
solidStart({
|
||||
middleware: "./src/middleware/index.ts",
|
||||
ssr:true,
|
||||
}),
|
||||
],
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user