diff --git a/.cursor/rules/code-style.mdc b/.cursor/rules/backend/code-style.mdc similarity index 93% rename from .cursor/rules/code-style.mdc rename to .cursor/rules/backend/code-style.mdc index f7c53cc..1fb8647 100644 --- a/.cursor/rules/code-style.mdc +++ b/.cursor/rules/backend/code-style.mdc @@ -1,10 +1,10 @@ --- -description: Code style, formatting, naming conventions, imports, and type rules -globs: ["src/**/*.ts", "src/**/*.tsx"] +description: Code style, formatting, naming conventions, imports, and type rules for the backend +globs: ["apps/backend/src/**/*.ts", "apps/backend/src/**/*.tsx"] alwaysApply: true --- -# Code Style Rules +# Backend Code Style Rules ## Formatting (Prettier) @@ -49,7 +49,7 @@ import { namespace } from './namespace'; ## Type System -- Define shared types in `src/types/` +- Define shared types in `apps/backend/src/types/` - Use `RouteItem` generic for typed route definitions: ```typescript type HomeRoute = RouteItem<{ code: number; data: HomeData[] }>; diff --git a/.cursor/rules/error-handling.mdc b/.cursor/rules/backend/error-handling.mdc similarity index 95% rename from .cursor/rules/error-handling.mdc rename to .cursor/rules/backend/error-handling.mdc index fd7f124..3938fed 100644 --- a/.cursor/rules/error-handling.mdc +++ b/.cursor/rules/backend/error-handling.mdc @@ -1,10 +1,10 @@ --- -description: Error handling patterns, status codes, and logging rules -globs: ["src/**/*.ts", "src/**/*.tsx"] +description: Error handling patterns, status codes, and logging rules for the backend +globs: ["apps/backend/src/**/*.ts", "apps/backend/src/**/*.tsx"] alwaysApply: true --- -# Error Handling Rules +# Backend Error Handling Rules ## Status Codes @@ -32,7 +32,6 @@ const handler = async (ctx) => { const res = await someRequest(/* ... */); if (res.code === 1) { - // Success path return { code: SUCCESS_CODE, message: SEARCH_MESSAGE.SUCCESS, diff --git a/.cursor/rules/general.mdc b/.cursor/rules/backend/general.mdc similarity index 56% rename from .cursor/rules/general.mdc rename to .cursor/rules/backend/general.mdc index e499b05..306b2d7 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/backend/general.mdc @@ -1,14 +1,14 @@ --- -description: General project overview, architecture, and development commands for VodHub -globs: ["**/*.ts", "**/*.tsx"] +description: General project overview, architecture, and development commands for the backend +globs: ["apps/backend/**/*.ts", "apps/backend/**/*.tsx"] alwaysApply: true --- -# VodHub General Rules +# VodHub Backend — General Rules ## Project Overview -VodHub is a video aggregation API service built with **Hono** on Node.js. It normalizes multiple video source providers into a unified REST API supporting categories, search, details, and playback. TypeScript throughout, pnpm as package manager. +VodHub backend is a video aggregation API service built with **Hono** on Node.js. It is part of the VodHub pnpm monorepo (`apps/backend`). Normalizes multiple video source providers into a unified REST API supporting categories, search, details, and playback. TypeScript throughout, pnpm as package manager. ## Tech Stack @@ -22,7 +22,7 @@ VodHub is a video aggregation API service built with **Hono** on Node.js. It nor ## Project Structure ``` -src/ +apps/backend/src/ index.ts # Server entry: boots @hono/node-server app.tsx # Hono app: global middleware + route mounting api/ # OpenAPI metadata routes @@ -38,21 +38,19 @@ src/ ## Development Commands ```bash -pnpm install # Install dependencies -pnpm dev # Start dev server with hot reload (tsx watch) -pnpm start # Start server without watch +pnpm install # Install all dependencies (monorepo root) +pnpm dev # Start both backend + frontend +pnpm dev:backend # Backend only (tsx watch) +pnpm --filter @vodhub/backend start # Start backend without watch +pnpm --filter @vodhub/backend lint # Lint backend +pnpm --filter @vodhub/backend lint:fix # Lint with auto-fix +pnpm --filter @vodhub/backend typecheck # Type check backend +pnpm format # Prettier write all apps +pnpm format:check # Prettier check all apps +pnpm commit # Interactive conventional commit ``` -No test framework is configured. Linting and type checking: - -```bash -npx eslint src/ --ext .ts,.tsx # Lint -npx eslint src/ --ext .ts,.tsx --fix # Lint with auto-fix -npx prettier --cache --write "src/**/*.{ts,tsx}" # Format -npx tsc --noEmit # Type check -``` - -Commits use **commitizen** with conventional commits. Run `pnpm commit` for the interactive prompt. +No test framework is configured. ## Route URL Pattern diff --git a/.cursor/rules/routes.mdc b/.cursor/rules/backend/routes.mdc similarity index 90% rename from .cursor/rules/routes.mdc rename to .cursor/rules/backend/routes.mdc index d165de2..8630602 100644 --- a/.cursor/rules/routes.mdc +++ b/.cursor/rules/backend/routes.mdc @@ -1,6 +1,6 @@ --- description: Rules for creating and modifying provider routes in VodHub -globs: ["src/routes/**/*.ts"] +globs: ["apps/backend/src/routes/**/*.ts"] alwaysApply: false --- @@ -8,9 +8,9 @@ alwaysApply: false ## Creating a New CMS Provider (Recommended) -For standard CMS sources, only `namespace.ts` and `index.ts` are needed: +For standard CMS sources, only `namespace.ts` and `index.ts` are needed in `apps/backend/src/routes//`: -1. Create `src/routes//` directory +1. Create `apps/backend/src/routes//` directory 2. Add `namespace.ts` with provider metadata 3. Add `index.ts` that uses the factory: ```typescript @@ -37,7 +37,7 @@ export const namespace: Namespace = { For non-CMS sources, create individual route files. Each file exports a single `route` object: ``` -src/routes// +apps/backend/src/routes// namespace.ts home.ts homeVod.ts @@ -99,7 +99,7 @@ Every route must conform to its typed `RouteItem`: ## Registry Auto-Discovery -The registry (`src/routes/registry.ts`) auto-discovers routes via `directory-import`: +The registry (`apps/backend/src/routes/registry.ts`) auto-discovers routes via `directory-import`: - `namespace` export → provider metadata - `routes` export → array of route objects (from factory) - `route` export → single route object (custom provider) diff --git a/.cursor/rules/frontend/rules/code-style.mdc b/.cursor/rules/frontend/rules/code-style.mdc new file mode 100644 index 0000000..1d0deb8 --- /dev/null +++ b/.cursor/rules/frontend/rules/code-style.mdc @@ -0,0 +1,124 @@ +--- +description: Frontend coding conventions and style guide +globs: ["apps/frontend/**/*.ts", "apps/frontend/**/*.tsx", "apps/frontend/**/*.scss"] +alwaysApply: true +--- + +# Frontend Code Style Rules + +## TypeScript Rules +- Strict mode (`strict: true`) +- `any` is allowed (`@typescript-eslint/no-explicit-any: off`) +- `@ts-ignore` is allowed (`@typescript-eslint/ban-ts-comment: off`) +- Prefer `interface` for object types +- API responses use generics (`async get(url, config): Promise`) + +## Naming Conventions +| Item | Convention | Example | +|---|---|---| +| Components | PascalCase | `InitProvider`, `VodList` | +| Hooks | camelCase + `use` prefix | `useIsMobile`, `useThemeStore` | +| Stores | camelCase + `use` + `Store` suffix | `useSettingStore` | +| Constants | UPPER_SNAKE_CASE | `DEFAULT_CONFIG` | +| Types | PascalCase | `HomeData`, `VodListProps` | +| Files | `.tsx` for components, `.ts` otherwise | | + +## Import Order +Enforced by ESLint `import/order`: +1. Node built-in modules +2. External dependency packages +3. Internal modules (`@/`), parent, sibling, index + +Blank line between each group, alphabetized within groups (case-insensitive). + +```typescript +// Example +import { Button } from 'antd'; +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; + +import useSettingStore from '@/lib/store/useSettingStore'; +import { useVodSitesStore } from '@/lib/store/useVodSitesStore'; +``` + +## Component Rules +- Client components must include `'use client'` directive +- Props defined with `interface` and destructured in params +- Components use `export default` + +```typescript +'use client'; +import { FC } from 'react'; + +export interface MyComponentProps { + title: string; + onClick?: () => void; +} + +const MyComponent: FC = ({ title, onClick }) => { + return
{title}
; +}; + +export default MyComponent; +``` + +## Styling Rules +- Use SCSS Modules (`index.module.scss`) +- Class names use `vod-next-` prefix +- **All colors must use CSS variables** — never hardcode hex/rgba values +- Ant Design components are preferred + +```scss +// Correct: CSS variables +background: var(--color-bg-container); +color: var(--color-text-secondary); +border: 1px solid var(--color-border-secondary); + +// Wrong: hardcoded colors +background: #0f0f23; +color: #94a3b8; +``` + +Available CSS variables (defined in `globals.scss`): +| Category | Variable | Description | +|---|---|---| +| Brand | `--color-primary` | Primary color | +| Brand | `--color-primary-light` | Light primary | +| Background | `--color-bg` | Page background | +| Background | `--color-bg-container` | Container background | +| Background | `--color-bg-elevated` | Elevated background | +| Background | `--color-bg-container-alpha` | Semi-transparent container | +| Background | `--color-bg-elevated-alpha` | Semi-transparent elevated | +| Background | `--color-bg-elevated-hover` | Hover state background | +| Text | `--color-text` | Primary text | +| Text | `--color-text-secondary` | Secondary text | +| Text | `--color-text-tertiary` | Tertiary text | +| Border | `--color-border` | Border | +| Border | `--color-border-secondary` | Secondary border | +| State | `--color-primary-alpha-low` | Selected state | +| State | `--color-primary-alpha-medium` | Border accent | +| State | `--color-primary-alpha-hover` | Hover state | +| State | `--color-primary-shadow` | Shadow | +| Overlay | `--color-overlay` | Overlay background | +| Overlay | `--color-overlay-border` | Overlay border | + +## Themes +Three built-in themes defined in `lib/themes/index.ts`: +- **midnight**: Dark with red accent +- **aurora**: Light with cyan accent +- **cyber**: Dark with purple accent + +Theme managed by `useThemeStore`, updates CSS variables and Ant Design theme config. + +## State Management +- Zustand stores use `persist` middleware +- Store file naming: `use{Name}Store.ts` +- localStorage operations via `store2` + +## Error Handling +- HTTP errors: rejected via interceptor (`Promise.reject(error)`) +- API calls: wrap in try/catch, re-throw as `Promise.reject` +- UI errors: display with Ant Design `message` component + +## Formatting (Prettier) +- Single quotes, no trailing commas, 4-space indent, 200 char width, LF line endings, semicolons always diff --git a/.cursor/rules/frontend/rules/project.mdc b/.cursor/rules/frontend/rules/project.mdc new file mode 100644 index 0000000..93d48f2 --- /dev/null +++ b/.cursor/rules/frontend/rules/project.mdc @@ -0,0 +1,45 @@ +--- +description: Project overview and tech stack for the frontend +globs: ["apps/frontend/**/*.ts", "apps/frontend/**/*.tsx", "apps/frontend/**/*.scss"] +alwaysApply: true +--- + +# VodHub Frontend — Project Overview + +VodNext is the frontend of the VodHub monorepo, a video aggregation player built with Next.js. + +## Core Features +- Next.js (React) with SSR and static generation +- Aggregates multiple video sources, supports category browsing, search, playback +- Multi-theme support (midnight, aurora, cyber) + +## Tech Stack +- **Framework**: Next.js 16 + React 19 + TypeScript +- **State Management**: Zustand + Valtio +- **UI Library**: Ant Design 6 +- **Styling**: SCSS Modules + CSS variables (multi-theme) +- **HTTP Client**: Axios +- **Video Player**: xgplayer + HLS.js +- **Package Manager**: pnpm (monorepo at root) + +## Development Tools +- **Linting**: ESLint (eslint-config-next) + Prettier +- **Git Hooks**: Husky + lint-staged +- **Commits**: Commitlint + commitizen (conventional commits) + +## Environment Requirements +- Node.js >= 24 +- pnpm >= 10 + +## Development Commands + +```bash +pnpm dev # Start both backend + frontend dev servers +pnpm dev:frontend # Frontend only (next dev) +pnpm --filter @vodhub/frontend build # Production build (standalone) +pnpm --filter @vodhub/frontend lint # ESLint check +pnpm --filter @vodhub/frontend typecheck # TypeScript type check +pnpm format # Prettier write all apps +pnpm format:check # Prettier check +pnpm commit # Interactive conventional commit +``` diff --git a/.cursor/rules/frontend/rules/structure.mdc b/.cursor/rules/frontend/rules/structure.mdc new file mode 100644 index 0000000..bc594a3 --- /dev/null +++ b/.cursor/rules/frontend/rules/structure.mdc @@ -0,0 +1,71 @@ +--- +description: Frontend directory structure and file organization +globs: ["apps/frontend/**/*.ts", "apps/frontend/**/*.tsx", "apps/frontend/**/*.scss"] +alwaysApply: true +--- + +# Frontend Structure + +## Directory Layout + +``` +apps/frontend/ + app/ # Next.js App Router pages + layout.tsx # Root layout + globals.scss # Global styles + CSS variables + layouts/ # Layout components (BasicLayout, header, disclaimer) + home/ # Home page + category/ # Category browsing + detail/ # Video detail + setting/ # Settings page + components/ # Shared components + providers/ # Context providers (InitProvider, ThemeProvider) + video/ # Video components (VodList, VodPalyer, VodSearch, VodSites, VodTypes) + icons/ # Custom SVG icon components + ui/ # UI utilities (Loading, ThemeSelector) + lib/ # Business logic and utilities + constant/ # Constants (llm.ts, site.ts, prompt.ts) + hooks/ # Custom hooks + store/ # Zustand stores + themes/ # Theme definitions (midnight, aurora, cyber) + types/ # TypeScript type definitions + utils/ # Utilities (HTTP request wrapper) + services/ # API service layer + index.ts # Service exports + vodhub/ # VodHub API calls +``` + +## File Organization + +### Pages +- Located in `app/` using Next.js App Router +- Each page directory: `page.tsx` + optional `index.module.scss` +- Client components require `'use client'` directive + +### Components +- Located in `components/` directory +- Component directory format: `ComponentName/index.tsx` + `index.module.scss` +- Props interfaces exported with `export interface` + +### State Management +- Located in `lib/store/` directory +- File naming: `use{Name}Store.ts` +- Uses Zustand + `persist` middleware + +### API Services +- Located in `services/` directory +- Organized by module (e.g., `vodhub/`) +- Uses generics for response types + +### Styles +- SCSS Modules (`.module.scss`) +- Class names prefixed with `vod-next-` +- All colors use CSS variables from `globals.scss` + +## Key File Locations +- Types: `lib/types/index.ts` +- HTTP wrapper: `lib/utils/request/index.ts` +- Theme config: `lib/themes/index.ts` +- Theme Provider: `components/providers/ThemeProvider.tsx` +- Theme store: `lib/store/useThemeStore.ts` +- Sites store: `lib/store/useVodSitesStore.ts` diff --git a/.cursor/rules/frontend/rules/workflow.mdc b/.cursor/rules/frontend/rules/workflow.mdc new file mode 100644 index 0000000..08e25c2 --- /dev/null +++ b/.cursor/rules/frontend/rules/workflow.mdc @@ -0,0 +1,75 @@ +--- +description: Frontend development workflow and common tasks +globs: ["apps/frontend/**/*.ts", "apps/frontend/**/*.tsx", "apps/frontend/**/*.scss"] +alwaysApply: true +--- + +# Frontend Development Workflow + +## Commands + +```bash +pnpm dev # Start both backend + frontend +pnpm dev:frontend # Frontend only (next dev) +pnpm --filter @vodhub/frontend build # Production build (standalone) +pnpm --filter @vodhub/frontend lint # ESLint check +pnpm --filter @vodhub/frontend typecheck # TypeScript type check +pnpm commit # Interactive conventional commit +``` + +## Git Workflow +- **Commit format**: Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, etc.) +- **Pre-commit**: Husky + lint-staged runs ESLint and Prettier automatically +- **Commit validation**: commitlint verifies message format + +## New Feature Flow +1. Create feature branch +2. Create page in `app/` (if needed) +3. Create components in `components/` +4. Add API calls in `services/` +5. Add type definitions in `lib/types/` +6. Run `pnpm --filter @vodhub/frontend lint` +7. Commit with `pnpm commit` + +## Common Tasks + +### Adding a Page +1. Create directory in `app/` (e.g., `app/newpage/`) +2. Create `page.tsx` +3. Add `'use client'` directive (if client-side features needed) +4. Create `index.module.scss` for styles + +### Adding a Component +1. Create directory in `components/` (e.g., `components/video/NewComponent/`) +2. Create `index.tsx` +3. Define and export Props interface +4. Create `index.module.scss` +5. Use `export default` for the component + +### Adding an API Call +1. Define request/response types in `lib/types/` +2. Add API function in `services/vodhub/` +3. Use `request.get` or `request.post` +4. Export in `services/index.ts` + +### Adding a Store +1. Create `use{Name}Store.ts` in `lib/store/` +2. Define interface and state types +3. Create store with Zustand `create` +4. Add `persist` middleware if persistence needed + +## Tips +- **Browser console**: Use `console.log` for debugging +- **Network**: Check `/api/vodhub/` proxy requests +- **State**: Use React DevTools or Zustand DevTools + +## Performance +- Use `React.memo` to avoid unnecessary re-renders +- Use `useMemo` and `useCallback` for expensive computations +- Use `dynamic` imports for heavy components (e.g., video player) +- Use `Suspense` for async loading + +## Notes +- **SSR**: Client components must have `'use client'` directive +- **Themes**: Styles must support light/dark themes via CSS variables +- **Responsive**: Use Ant Design `Flex` and `Row/Col` for layout diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f1df0f8..aff5c61 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -12,7 +12,18 @@ updates: interval: weekly - package-ecosystem: 'npm' - directory: '/' + directory: '/apps/backend' + schedule: + interval: monthly + day: friday + time: '12:00' + timezone: Asia/Singapore + open-pull-requests-limit: 10 + labels: + - 'dependencies' + + - package-ecosystem: 'npm' + directory: '/apps/frontend' schedule: interval: monthly day: friday diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..495ebe3 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,27 @@ +frontend: + - changed-files: + - any-glob-to-any-file: + - 'apps/frontend/app/**' + - 'apps/frontend/components/**' + +backend: + - changed-files: + - any-glob-to-any-file: + - 'apps/backend/src/**' + +styles: + - changed-files: + - any-glob-to-any-file: + - '**/*.scss' + - '**/*.css' + +ci: + - changed-files: + - any-glob-to-any-file: + - '.github/**' + +dependencies: + - changed-files: + - any-glob-to-any-file: + - 'apps/*/package.json' + - 'pnpm-lock.yaml' diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 0000000..20ca5d1 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,28 @@ +name: Security Audit + +on: + schedule: + - cron: '0 0 * * 1' # 每周一 UTC 00:00 + workflow_dispatch: + +jobs: + audit: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Audit + run: pnpm audit --audit-level=high diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image-backend.yml similarity index 97% rename from .github/workflows/docker-image.yml rename to .github/workflows/docker-image-backend.yml index e2af442..8bffbf4 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image-backend.yml @@ -40,7 +40,7 @@ jobs: - name: 构建并推送镜像 uses: docker/build-push-action@v6 with: - context: . + context: ./apps/backend platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} diff --git a/.github/workflows/docker-image-frontend.yml b/.github/workflows/docker-image-frontend.yml new file mode 100644 index 0000000..1554a08 --- /dev/null +++ b/.github/workflows/docker-image-frontend.yml @@ -0,0 +1,47 @@ +name: Docker Image (Frontend) + +on: + workflow_dispatch: + release: + types: [published] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 检查代码 + uses: actions/checkout@v4 + + - name: 设置 QEMU + uses: docker/setup-qemu-action@v3 + + - name: 设置 Buildx + uses: docker/setup-buildx-action@v3 + + - name: 登录 DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: 生成镜像标签 + id: meta + uses: docker/metadata-action@v5 + with: + images: consistentlee/vod_next + tags: | + type=raw,value=latest + type=ref,event=tag + type=sha,prefix= + + - name: 构建并推送镜像 + uses: docker/build-push-action@v6 + with: + context: ./apps/frontend + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/first-interaction.yml b/.github/workflows/first-interaction.yml new file mode 100644 index 0000000..f2731c5 --- /dev/null +++ b/.github/workflows/first-interaction.yml @@ -0,0 +1,21 @@ +name: First Interaction + +on: + pull_request_target: + types: [opened] + issues: + types: [opened] + +jobs: + welcome: + runs-on: ubuntu-latest + steps: + - uses: actions/first-interaction@v1.3.0 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + pr-message: >- + Thanks for your first contribution! Please make sure your PR follows our commit conventions (conventional commits). We'll review it soon. + + issue-message: >- + Thanks for opening your first issue! We'll take a look and get back to you. + diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 0000000..dd6a72d --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,17 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +permissions: + contents: read + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v5 + with: + sync-labels: true diff --git a/.gitignore b/.gitignore index 44e2b17..066808d 100644 --- a/.gitignore +++ b/.gitignore @@ -27,5 +27,23 @@ lerna-debug.log* # misc .DS_Store -#production -/dist \ No newline at end of file +# production +/dist +apps/*/dist/ + +# next.js +.next/ +apps/*/.next/ + +# misc +.DS_Store +*.pem + + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + diff --git a/AGENTS.md b/AGENTS.md index 26d62b3..8f43673 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,123 +2,174 @@ ## Project Overview -VodHub is a video aggregation API service built with **Hono** (web framework) on Node.js. It normalizes multiple video source providers into a unified REST API supporting categories, search, details, and playback. TypeScript throughout. pnpm as package manager. +VodHub is a **pnpm monorepo** with two apps: +- **`apps/backend`** (`@vodhub/backend`): Hono-based video aggregation API. Normalizes multiple video source providers into a unified REST API (categories, search, details, playback). Node >= 24, ESM. +- **`apps/frontend`** (`@vodhub/frontend`): Next.js 16 + React 19 video player app. Ant Design 6, Zustand, SCSS Modules with multi-theme support. ## Build / Dev Commands ```bash -pnpm install # Install dependencies -pnpm dev # Start dev server with hot reload (tsx watch) -pnpm start # Start server without watch -``` - -There is **no test framework** configured in this project. Do not assume one exists. - -### Linting & Formatting +pnpm install # Install all dependencies +pnpm dev # Start both backend + frontend dev servers +pnpm dev:backend # Backend only (tsx watch) +pnpm dev:frontend # Frontend only (next dev) -No npm scripts for lint/format. Run directly: +# Linting +pnpm lint # Lint all apps (pnpm -r lint) +pnpm --filter @vodhub/backend lint # Backend only +pnpm --filter @vodhub/backend lint:fix # Backend auto-fix -```bash -npx eslint src/ --ext .ts,.tsx # Lint -npx eslint src/ --ext .ts,.tsx --fix # Lint with auto-fix -npx prettier --cache --write "src/**/*.{ts,tsx}" # Format -``` +# Type checking +pnpm typecheck # Typecheck all apps (pnpm -r typecheck) -Lint-staged runs automatically on commit via Husky for `.ts`, `.tsx`, `.js`, `.md`, `.json` files. +# Formatting +pnpm format # Prettier write on apps/*/src/**/*.{ts,tsx} +pnpm format:check # Prettier check -### Type Checking +# Backend start (no watch) +pnpm --filter @vodhub/backend start -No `tsc` script in package.json. Run manually: +# Frontend build +pnpm --filter @vodhub/frontend build -```bash -npx tsc --noEmit +# Commits (interactive, conventional) +pnpm commit ``` -### Commits +There is **no test framework** configured. Do not assume one exists. -Uses **commitizen** with conventional commits (`@commitlint/config-conventional`). Run `pnpm commit` to use the interactive prompt. +Lint-staged runs automatically on commit via Husky. Commit messages are validated by commitlint (conventional commits). -## Architecture +## Monorepo Structure ``` -src/ - index.ts # Server entry: boots @hono/node-server - app.tsx # Hono app: global middleware + route mounting - api/ # OpenAPI metadata routes - config/ # App config (port, cache TTL, banned keywords) - constant/ # Status codes, messages, user-agents, word lists - middleware/ # Cache middleware, JSON response middleware - routes/ # Provider route directories (auto-discovered) - registry.ts # Dynamic route loader using directory-import - types/ # Core types (Namespace, Route, HomeData, etc.) - utils/ # Shared CMS handlers, cache, logger, filters +apps/ + backend/ + src/ + index.ts # Server entry: boots @hono/node-server + app.tsx # Hono app: middleware + route mounting + api/ # OpenAPI metadata routes + config/ # App config (port, cache TTL, banned keywords) + constant/ # Status codes, messages, user-agents, word lists + middleware/ # Cache middleware, JSON response middleware + routes/ # Provider route directories (auto-discovered) + registry.ts # Dynamic route loader using directory-import + types/ # Core types (Namespace, Route, HomeData, etc.) + utils/ # Shared CMS handlers, cache, logger, filters + frontend/ + app/ # Next.js App Router pages + components/ # Providers, video, icons, UI components + lib/ # Constants, hooks, stores, themes, types, utils + services/ # API service layer ``` Route URL pattern: `GET/POST /api/vodhub//` Actions per provider: `home`, `homeVod`, `category`, `detail`, `play`, `search` -## Adding a New Provider Route +## Adding a Backend Provider Route ### CMS-Based Provider (Recommended) -For standard CMS sources, only `namespace.ts` and `index.ts` are needed: +Only `namespace.ts` and `index.ts` are needed in `apps/backend/src/routes//`: + +```typescript +// namespace.ts +import type { Namespace } from '@/types'; +export const namespace: Namespace = { + name: 'Provider Name', + url: 'https://example.com', + description: 'Provider description' +}; + +// index.ts +import { namespace } from './namespace'; +import { createCMSRoutes } from '@/utils/cms/factory'; +export const routes = createCMSRoutes(namespace); +``` -1. Create `src/routes//` directory -2. Add `namespace.ts` with provider metadata -3. Add `index.ts` that uses the factory: - ```typescript - import { namespace } from './namespace'; - import { createCMSRoutes } from '@/utils/cms/factory'; - export const routes = createCMSRoutes(namespace); - ``` -4. Routes auto-register via `directory-import` in `registry.ts` +Routes auto-register via `directory-import` in `registry.ts`. ### Custom Provider -For non-CMS sources (e.g., 360kan), create individual route files: -`namespace.ts`, `home.ts`, `homeVod.ts`, `category.ts`, `detail.ts`, `play.ts`, `search.ts` +For non-CMS sources, create individual route files: +`namespace.ts`, `home.ts`, `homeVod.ts`, `category.ts`, `detail.ts`, `play.ts`, `search.ts`. Each exports a `route` object conforming to the typed `Route` interface. ## Code Style ### Formatting (Prettier) -- Single quotes -- No trailing commas +- Single quotes, no trailing commas - 4-space indentation (`tabWidth: 4`) -- 200 char print width -- LF line endings +- 200 char print width, LF line endings, semicolons always -### Imports -- Use `@/` path alias for `src/` (e.g., `import { namespace } from '@/types'`) +### Imports (both apps) +- Use `@/` path alias: backend maps to `src/`, frontend maps to app root - Relative imports within the same provider directory (e.g., `./namespace`) - Use `node:` prefix for Node built-ins (e.g., `import { join } from 'node:path'`) -- Import order (enforced by ESLint): builtins → externals → internals, blank line between groups, alphabetized within groups +- Import order (enforced by ESLint): **builtins → externals → internals**, blank line between groups, alphabetized within groups +- Use `import type` for type-only imports + +### Naming Conventions +| Item | Convention | Example | +|---|---|---| +| Variables, functions, params | `camelCase` | `getLocalhostAddress`, `cacheKey` | +| Types, interfaces, classes | `PascalCase` | `HomeData`, `RouteItem` | +| Constants | `UPPER_SNAKE_CASE` | `SUCCESS_CODE`, `BANNED_KEYWORDS` | +| Handler functions | Always `handler` | `const handler = async (ctx) => { ... }` | +| Frontend components | PascalCase | `VodList`, `InitProvider` | +| Frontend hooks | `use` prefix | `useIsMobile`, `useThemeStore` | +| Frontend stores | `use` + `Store` suffix | `useSettingStore`, `useThemeStore` | + +### Types (Backend) +- Shared types in `apps/backend/src/types/` +- Use `RouteItem` generic: `type HomeRoute = RouteItem<{ code: number; data: HomeData[] }>` +- `noImplicitAny` is `false`, but ESLint enforces `no-explicit-any: "error"` + +### Types (Frontend) +- Shared types in `apps/frontend/lib/types/` +- `any` is allowed (`no-explicit-any: off`) +- Prefer `interface` for object types; API responses use generics -### Naming -- `camelCase` for variables, functions, parameters -- `PascalCase` for types, interfaces, classes -- `UPPER_SNAKE_CASE` for constants (e.g., `SUCCESS_CODE`, `BANNED_KEYWORDS`) -- Handler functions always named `handler` -- Route files use standard names: `namespace.ts`, `home.ts`, `category.ts`, `detail.ts`, `play.ts`, `search.ts` +### Exports +- **Backend**: Named exports only (except `RequestInProgressError`) +- **Frontend**: Components use `export default`; utilities/stores use named exports + +## Error Handling (Backend) + +Three status codes: `SUCCESS_CODE=0`, `ERROR_CODE=-1`, `SYSTEM_ERROR_CODE=-2` + +Every handler must follow this pattern: +```typescript +const handler = async (ctx: Context) => { + try { + logger.info(`${ACTION_MESSAGE.INFO} - ${namespace.name}`); + const res = await someRequest(); + if (res.code === 1) { + return { code: SUCCESS_CODE, message: ACTION_MESSAGE.SUCCESS, data: [...] }; + } + logger.error(`${ACTION_MESSAGE.ERROR} - ${namespace.name} - ${JSON.stringify(res)}`); + return { code: ERROR_CODE, message: ACTION_MESSAGE.ERROR, data: [] }; + } catch (error) { + ctx.res.headers.set('Cache-Control', 'no-cache'); + logger.error(`${ACTION_MESSAGE.ERROR} - ${namespace.name} - ${error}`); + return { code: SYSTEM_ERROR_CODE, message: ACTION_MESSAGE.ERROR, data: [] }; + } +}; +``` -### Types -- Define shared types in `src/types/` -- Use `RouteItem` generic for typed route definitions (e.g., `type HomeRoute = RouteItem<{ code: number; data: HomeData[] }>`) -- Use `Namespace` type for provider metadata -- `noImplicitAny` is `false` — explicit type annotations are not enforced on all parameters, but prefer them for public APIs +- Never throw — always return `{ code, message, data }` +- Set `Cache-Control: no-cache` in catch blocks +- Always return `data: []` on failure +- Use message constants from `@/constant/message` +- CMS handlers check `res.code === 1` for upstream success -### Exports -- **Named exports only** (no default exports, except custom error classes) -- Each route file exports a `route` object conforming to the `Route` type -- Each namespace file exports a `namespace` object conforming to `Namespace` - -### Error Handling -- Three status codes: `SUCCESS_CODE=0`, `ERROR_CODE=-1`, `SYSTEM_ERROR_CODE=-2` -- Never throw errors to the framework — always return structured `{ code, message, data }` objects -- On error, set `Cache-Control: no-cache` header to prevent caching failed responses -- Log every error with `logger.error()` including namespace name -- Use try/catch in every handler; return `data: []` on failure - -### General Rules +## Frontend Style Rules +- Client components require `'use client'` directive +- Use SCSS Modules (`index.module.scss`) with `vod-next-` class prefix +- All colors must use CSS variables (e.g., `var(--color-bg-container)`) — never hardcode hex/rgba +- State management: Zustand with `persist` middleware, stores in `lib/store/` +- API calls: wrap in try/catch, use `request.get` / `request.post` + +## General Rules - No comments unless explicitly requested - Use `const` over `let`; avoid `var` - Prefer `async/await` over `.then()` chains diff --git a/Dockerfile b/apps/backend/Dockerfile similarity index 89% rename from Dockerfile rename to apps/backend/Dockerfile index caeb111..c67023a 100644 --- a/Dockerfile +++ b/apps/backend/Dockerfile @@ -15,7 +15,6 @@ RUN \ set -ex && \ echo 'use npm mirror' && \ npm config set registry https://registry.npmmirror.com && \ - yarn config set registry https://registry.npmmirror.com && \ pnpm config set registry https://registry.npmmirror.com COPY ./package.json /app/ @@ -39,4 +38,4 @@ COPY --from=deps /app /app # 暴露端口 EXPOSE 8888 # 启动应用 -ENTRYPOINT ["npm", "run", "start"] \ No newline at end of file +ENTRYPOINT ["npm", "run", "start"] diff --git a/README.md b/apps/backend/README.md similarity index 100% rename from README.md rename to apps/backend/README.md diff --git a/apps/backend/package.json b/apps/backend/package.json new file mode 100644 index 0000000..dc1a7d2 --- /dev/null +++ b/apps/backend/package.json @@ -0,0 +1,53 @@ +{ + "name": "@vodhub/backend", + "version": "0.1.0", + "keywords": [ + "电影", + "视频", + "电视剧", + "综艺", + "动漫" + ], + "type": "module", + "scripts": { + "dev": "tsx watch --clear-screen=false src/index.ts", + "start": "tsx src/index.ts", + "lint": "eslint src/", + "lint:fix": "eslint src/ --fix", + "typecheck": "tsc --noEmit", + "format": "prettier --cache --write \"src/**/*.{ts,tsx}\"", + "format:check": "prettier --cache --check \"src/**/*.{ts,tsx}\"" + }, + "dependencies": { + "@hono/node-server": "^1.19.11", + "@hono/zod-openapi": "^1.2.2", + "@keyv/redis": "^5.1.6", + "axios": "^1.13.6", + "cache-manager": "^7.2.8", + "cacheable": "^2.3.4", + "cross-env": "^10.1.0", + "crypto-js": "^4.2.0", + "dayjs": "^1.11.20", + "directory-import": "^3.3.2", + "dotenv": "^17.3.1", + "hono": "^4.12.8", + "ioredis": "^5.10.1", + "keyv": "^5.6.0", + "lodash": "^4.17.23", + "query-string": "^9.3.1", + "winston": "^3.19.0", + "@vodhub/shared": "workspace:*" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/crypto-js": "^4.2.2", + "@types/lodash": "^4.17.24", + "@types/node": "^25.3.3", + "eslint": "^10.1.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.16.2", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.57.1" + } +} diff --git a/src/api/index.ts b/apps/backend/src/api/index.ts similarity index 100% rename from src/api/index.ts rename to apps/backend/src/api/index.ts diff --git a/src/api/namespace/index.ts b/apps/backend/src/api/namespace/index.ts similarity index 100% rename from src/api/namespace/index.ts rename to apps/backend/src/api/namespace/index.ts diff --git a/src/app.tsx b/apps/backend/src/app.tsx similarity index 100% rename from src/app.tsx rename to apps/backend/src/app.tsx diff --git a/src/config/index.ts b/apps/backend/src/config/index.ts similarity index 100% rename from src/config/index.ts rename to apps/backend/src/config/index.ts diff --git a/src/constant/code.ts b/apps/backend/src/constant/code.ts similarity index 100% rename from src/constant/code.ts rename to apps/backend/src/constant/code.ts diff --git a/src/constant/message.ts b/apps/backend/src/constant/message.ts similarity index 100% rename from src/constant/message.ts rename to apps/backend/src/constant/message.ts diff --git a/src/constant/userAgent.ts b/apps/backend/src/constant/userAgent.ts similarity index 100% rename from src/constant/userAgent.ts rename to apps/backend/src/constant/userAgent.ts diff --git a/src/constant/word.ts b/apps/backend/src/constant/word.ts similarity index 100% rename from src/constant/word.ts rename to apps/backend/src/constant/word.ts diff --git a/src/index.ts b/apps/backend/src/index.ts similarity index 100% rename from src/index.ts rename to apps/backend/src/index.ts diff --git a/src/middleware/cache.ts b/apps/backend/src/middleware/cache.ts similarity index 100% rename from src/middleware/cache.ts rename to apps/backend/src/middleware/cache.ts diff --git a/src/middleware/jsonReturn.ts b/apps/backend/src/middleware/jsonReturn.ts similarity index 100% rename from src/middleware/jsonReturn.ts rename to apps/backend/src/middleware/jsonReturn.ts diff --git a/src/routes/360kan/category.ts b/apps/backend/src/routes/360kan/category.ts similarity index 100% rename from src/routes/360kan/category.ts rename to apps/backend/src/routes/360kan/category.ts diff --git a/src/routes/360kan/detail.ts b/apps/backend/src/routes/360kan/detail.ts similarity index 100% rename from src/routes/360kan/detail.ts rename to apps/backend/src/routes/360kan/detail.ts diff --git a/src/routes/360kan/home.ts b/apps/backend/src/routes/360kan/home.ts similarity index 100% rename from src/routes/360kan/home.ts rename to apps/backend/src/routes/360kan/home.ts diff --git a/src/routes/360kan/homeVod.ts b/apps/backend/src/routes/360kan/homeVod.ts similarity index 100% rename from src/routes/360kan/homeVod.ts rename to apps/backend/src/routes/360kan/homeVod.ts diff --git a/src/routes/360kan/namespace.ts b/apps/backend/src/routes/360kan/namespace.ts similarity index 100% rename from src/routes/360kan/namespace.ts rename to apps/backend/src/routes/360kan/namespace.ts diff --git a/src/routes/360kan/play.ts b/apps/backend/src/routes/360kan/play.ts similarity index 100% rename from src/routes/360kan/play.ts rename to apps/backend/src/routes/360kan/play.ts diff --git a/src/routes/360kan/request.ts b/apps/backend/src/routes/360kan/request.ts similarity index 100% rename from src/routes/360kan/request.ts rename to apps/backend/src/routes/360kan/request.ts diff --git a/src/routes/360kan/search.ts b/apps/backend/src/routes/360kan/search.ts similarity index 100% rename from src/routes/360kan/search.ts rename to apps/backend/src/routes/360kan/search.ts diff --git a/src/routes/360zy/index.ts b/apps/backend/src/routes/360zy/index.ts similarity index 100% rename from src/routes/360zy/index.ts rename to apps/backend/src/routes/360zy/index.ts diff --git a/src/routes/360zy/namespace.ts b/apps/backend/src/routes/360zy/namespace.ts similarity index 100% rename from src/routes/360zy/namespace.ts rename to apps/backend/src/routes/360zy/namespace.ts diff --git a/src/routes/bdzy/index.ts b/apps/backend/src/routes/bdzy/index.ts similarity index 100% rename from src/routes/bdzy/index.ts rename to apps/backend/src/routes/bdzy/index.ts diff --git a/src/routes/bdzy/namespace.ts b/apps/backend/src/routes/bdzy/namespace.ts similarity index 100% rename from src/routes/bdzy/namespace.ts rename to apps/backend/src/routes/bdzy/namespace.ts diff --git a/src/routes/bfzy/index.ts b/apps/backend/src/routes/bfzy/index.ts similarity index 100% rename from src/routes/bfzy/index.ts rename to apps/backend/src/routes/bfzy/index.ts diff --git a/src/routes/bfzy/namespace.ts b/apps/backend/src/routes/bfzy/namespace.ts similarity index 100% rename from src/routes/bfzy/namespace.ts rename to apps/backend/src/routes/bfzy/namespace.ts diff --git a/src/routes/feifan/index.ts b/apps/backend/src/routes/feifan/index.ts similarity index 100% rename from src/routes/feifan/index.ts rename to apps/backend/src/routes/feifan/index.ts diff --git a/src/routes/feifan/namespace.ts b/apps/backend/src/routes/feifan/namespace.ts similarity index 100% rename from src/routes/feifan/namespace.ts rename to apps/backend/src/routes/feifan/namespace.ts diff --git a/src/routes/guangsuzy/index.ts b/apps/backend/src/routes/guangsuzy/index.ts similarity index 100% rename from src/routes/guangsuzy/index.ts rename to apps/backend/src/routes/guangsuzy/index.ts diff --git a/src/routes/guangsuzy/namespace.ts b/apps/backend/src/routes/guangsuzy/namespace.ts similarity index 100% rename from src/routes/guangsuzy/namespace.ts rename to apps/backend/src/routes/guangsuzy/namespace.ts diff --git a/src/routes/hongniuzy/index.ts b/apps/backend/src/routes/hongniuzy/index.ts similarity index 100% rename from src/routes/hongniuzy/index.ts rename to apps/backend/src/routes/hongniuzy/index.ts diff --git a/src/routes/hongniuzy/namespace.ts b/apps/backend/src/routes/hongniuzy/namespace.ts similarity index 100% rename from src/routes/hongniuzy/namespace.ts rename to apps/backend/src/routes/hongniuzy/namespace.ts diff --git a/src/routes/huyazy/index.ts b/apps/backend/src/routes/huyazy/index.ts similarity index 100% rename from src/routes/huyazy/index.ts rename to apps/backend/src/routes/huyazy/index.ts diff --git a/src/routes/huyazy/namespace.ts b/apps/backend/src/routes/huyazy/namespace.ts similarity index 100% rename from src/routes/huyazy/namespace.ts rename to apps/backend/src/routes/huyazy/namespace.ts diff --git a/src/routes/ikunzy/index.ts b/apps/backend/src/routes/ikunzy/index.ts similarity index 100% rename from src/routes/ikunzy/index.ts rename to apps/backend/src/routes/ikunzy/index.ts diff --git a/src/routes/ikunzy/namespace.ts b/apps/backend/src/routes/ikunzy/namespace.ts similarity index 100% rename from src/routes/ikunzy/namespace.ts rename to apps/backend/src/routes/ikunzy/namespace.ts diff --git a/src/routes/lzzy/index.ts b/apps/backend/src/routes/lzzy/index.ts similarity index 100% rename from src/routes/lzzy/index.ts rename to apps/backend/src/routes/lzzy/index.ts diff --git a/src/routes/lzzy/namespace.ts b/apps/backend/src/routes/lzzy/namespace.ts similarity index 100% rename from src/routes/lzzy/namespace.ts rename to apps/backend/src/routes/lzzy/namespace.ts diff --git a/src/routes/mdzy/index.ts b/apps/backend/src/routes/mdzy/index.ts similarity index 100% rename from src/routes/mdzy/index.ts rename to apps/backend/src/routes/mdzy/index.ts diff --git a/src/routes/mdzy/namespace.ts b/apps/backend/src/routes/mdzy/namespace.ts similarity index 100% rename from src/routes/mdzy/namespace.ts rename to apps/backend/src/routes/mdzy/namespace.ts diff --git a/src/routes/proxy.ts b/apps/backend/src/routes/proxy.ts similarity index 100% rename from src/routes/proxy.ts rename to apps/backend/src/routes/proxy.ts diff --git a/src/routes/registry.ts b/apps/backend/src/routes/registry.ts similarity index 100% rename from src/routes/registry.ts rename to apps/backend/src/routes/registry.ts diff --git a/src/routes/sdzy/index.ts b/apps/backend/src/routes/sdzy/index.ts similarity index 100% rename from src/routes/sdzy/index.ts rename to apps/backend/src/routes/sdzy/index.ts diff --git a/src/routes/sdzy/namespace.ts b/apps/backend/src/routes/sdzy/namespace.ts similarity index 100% rename from src/routes/sdzy/namespace.ts rename to apps/backend/src/routes/sdzy/namespace.ts diff --git a/src/routes/subozy/index.ts b/apps/backend/src/routes/subozy/index.ts similarity index 100% rename from src/routes/subozy/index.ts rename to apps/backend/src/routes/subozy/index.ts diff --git a/src/routes/subozy/namespace.ts b/apps/backend/src/routes/subozy/namespace.ts similarity index 100% rename from src/routes/subozy/namespace.ts rename to apps/backend/src/routes/subozy/namespace.ts diff --git a/src/types/cms.ts b/apps/backend/src/types/cms.ts similarity index 100% rename from src/types/cms.ts rename to apps/backend/src/types/cms.ts diff --git a/src/types/error.ts b/apps/backend/src/types/error.ts similarity index 100% rename from src/types/error.ts rename to apps/backend/src/types/error.ts diff --git a/apps/backend/src/types/index.ts b/apps/backend/src/types/index.ts new file mode 100644 index 0000000..54ecfff --- /dev/null +++ b/apps/backend/src/types/index.ts @@ -0,0 +1,20 @@ +import type { Context } from 'hono'; +export type { Namespace, HomeData, HomeVodData, CategoryVodData, VodPlayList, DetailData, PlayData, SearchData, ApiResponse } from '@vodhub/shared'; +export type { Namespace as INamespace } from '@vodhub/shared'; + +export interface RouteItem { + path: string | string[]; + method?: 'GET' | 'POST'; + name: string; + handler: (ctx: Context) => Promise | T; + example: string; + description?: string; +} + +export type Route = RouteItem; +export type HomeRoute = RouteItem<{ code: number; data: import('@vodhub/shared').HomeData[] }>; +export type HomeVodRoute = RouteItem<{ code: number; data: import('@vodhub/shared').HomeVodData[] }>; +export type CategoryRoute = RouteItem<{ code: number; data: import('@vodhub/shared').CategoryVodData[] }>; +export type DetailRoute = RouteItem<{ code: number; data: import('@vodhub/shared').DetailData[] }>; +export type PlayRoute = RouteItem<{ code: number; data: import('@vodhub/shared').PlayData[] }>; +export type SearchRoute = RouteItem<{ code: number; data: import('@vodhub/shared').SearchData[] }>; diff --git a/src/utils/cache/index.ts b/apps/backend/src/utils/cache/index.ts similarity index 100% rename from src/utils/cache/index.ts rename to apps/backend/src/utils/cache/index.ts diff --git a/src/utils/cms/category/index.ts b/apps/backend/src/utils/cms/category/index.ts similarity index 100% rename from src/utils/cms/category/index.ts rename to apps/backend/src/utils/cms/category/index.ts diff --git a/src/utils/cms/detail/index.ts b/apps/backend/src/utils/cms/detail/index.ts similarity index 100% rename from src/utils/cms/detail/index.ts rename to apps/backend/src/utils/cms/detail/index.ts diff --git a/src/utils/cms/factory.ts b/apps/backend/src/utils/cms/factory.ts similarity index 100% rename from src/utils/cms/factory.ts rename to apps/backend/src/utils/cms/factory.ts diff --git a/src/utils/cms/home/index.ts b/apps/backend/src/utils/cms/home/index.ts similarity index 100% rename from src/utils/cms/home/index.ts rename to apps/backend/src/utils/cms/home/index.ts diff --git a/src/utils/cms/homeVod/index.ts b/apps/backend/src/utils/cms/homeVod/index.ts similarity index 100% rename from src/utils/cms/homeVod/index.ts rename to apps/backend/src/utils/cms/homeVod/index.ts diff --git a/src/utils/cms/play/index.ts b/apps/backend/src/utils/cms/play/index.ts similarity index 100% rename from src/utils/cms/play/index.ts rename to apps/backend/src/utils/cms/play/index.ts diff --git a/src/utils/cms/request.ts b/apps/backend/src/utils/cms/request.ts similarity index 100% rename from src/utils/cms/request.ts rename to apps/backend/src/utils/cms/request.ts diff --git a/src/utils/cms/search/index.ts b/apps/backend/src/utils/cms/search/index.ts similarity index 100% rename from src/utils/cms/search/index.ts rename to apps/backend/src/utils/cms/search/index.ts diff --git a/src/utils/common-utils.ts b/apps/backend/src/utils/common-utils.ts similarity index 100% rename from src/utils/common-utils.ts rename to apps/backend/src/utils/common-utils.ts diff --git a/src/utils/filters/index.ts b/apps/backend/src/utils/filters/index.ts similarity index 91% rename from src/utils/filters/index.ts rename to apps/backend/src/utils/filters/index.ts index a5d996a..845fc24 100644 --- a/src/utils/filters/index.ts +++ b/apps/backend/src/utils/filters/index.ts @@ -15,6 +15,6 @@ export const filterHomeVodData = (data: HomeVodData[]) => { // 过滤搜索数据 export const filterSearchData = (data: SearchData[]) => { - const newList = data.filter((item) => !config.bannedKeywords.some((keyword) => item.type_name.includes(keyword))); + const newList = data.filter((item) => !config.bannedKeywords.some((keyword) => (item.type_name ?? '').includes(keyword))); return newList; }; diff --git a/src/utils/format/index.ts b/apps/backend/src/utils/format/index.ts similarity index 100% rename from src/utils/format/index.ts rename to apps/backend/src/utils/format/index.ts diff --git a/src/utils/headers/index.ts b/apps/backend/src/utils/headers/index.ts similarity index 100% rename from src/utils/headers/index.ts rename to apps/backend/src/utils/headers/index.ts diff --git a/src/utils/logger/index.ts b/apps/backend/src/utils/logger/index.ts similarity index 100% rename from src/utils/logger/index.ts rename to apps/backend/src/utils/logger/index.ts diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json new file mode 100644 index 0000000..841b31f --- /dev/null +++ b/apps/backend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ESNext", + "jsx": "react-jsx", + "jsxImportSource": "hono/jsx", + "paths": { + "@/*": ["./src/*"] + }, + "noImplicitAny": false, + "outDir": "./dist" + }, + "exclude": ["node_modules", "*.test.*"] +} diff --git a/apps/frontend/Dockerfile b/apps/frontend/Dockerfile new file mode 100644 index 0000000..0714f15 --- /dev/null +++ b/apps/frontend/Dockerfile @@ -0,0 +1,61 @@ +# -------------------- Base Image -------------------- +FROM node:22-bookworm AS base + +# -------------------- Dependencies Stage -------------------- +FROM base AS deps + +WORKDIR /app + +RUN npm install -g corepack + +# 启用 pnpm +RUN corepack enable pnpm + +RUN \ + set -ex && \ + echo 'use npm mirror' && \ + npm config set registry https://registry.npmmirror.com && \ + yarn config set registry https://registry.npmmirror.com && \ + pnpm config set registry https://registry.npmmirror.com + + +COPY ./package.json /app/ +COPY ./pnpm-lock.yaml /app/ + +RUN \ + set -ex && \ + pnpm install --frozen-lockfile && \ + pnpm rebuild + +# -------------------- Build Stage -------------------- +FROM node:22-bookworm-slim AS builder + +WORKDIR /app + +COPY . /app +COPY --from=deps /app /app + +RUN \ + set -ex && \ + npm run build && \ + ls -la /app && \ + du -hd1 /app + +# -------------------- Runner Stage -------------------- +FROM node:22-bookworm-slim AS runner + +WORKDIR /app + +COPY --from=builder /app/public ./public +COPY --from=builder /app/.next/standalone ./ +COPY --from=builder /app/.next/static ./.next/static +COPY --from=builder /app/.next/server ./.next/server + +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" +ENV NEXT_SHARP_PATH=node_modules/sharp + +# 暴露端口 +EXPOSE 3000 +# 启动应用 +ENTRYPOINT ["node", "server.js"] \ No newline at end of file diff --git a/apps/frontend/README.md b/apps/frontend/README.md new file mode 100644 index 0000000..8868b42 --- /dev/null +++ b/apps/frontend/README.md @@ -0,0 +1,71 @@ +
+

VodNext

+ 一个视频源聚合播放器 +
+ +## ✨ 功能点 + +- 📦 支持VodHub标准视频源。 +- ⚙️ 提供视频分类、搜索、详情、播放等页面。 +- 🐳 支持PC、手机端页面自适应。 +- 🌛 支持一键切换至暗黑模式。 +- ⚙ 支持自定义网站名称。 + + +#### 首页 + +![alt text](/docs/images/home.png) + +#### 搜索 +![alt text](/docs/images/search.png) + +#### 播放页 +![alt text](/docs/images/play.png) + +#### 暗黑模式 +![alt text](/docs/images/dark.png) + +#### 手机适配 +![alt text](/docs/images/mobile.png) + + +## 🖥 开发环境 +环境配置文档: [Docs](https://consistent-k.github.io/docs/environment/nodejs.html) + +- Node.js 22+ +- PNPM 9+ + +## ⌨️ 本地启动 + +```bash +$ git clone git@github.com:consistent-k/VodNext.git +$ cd VodNext +$ pnpm install +$ pnpm dev +``` +> 启动成功后访问 http://127.0.0.1:3000/setting 进行配置 + +## 🔧 配置VodHub接口地址 +[一分钟了解VodHub](https://github.com/consistent-k/VodHub) + +#### 方式一 +本地启动VodHub服务,配置为`/` 即可 + +#### 方式二 +部署后的VodHub地址,配置为 `http://abc.com` 即可 + + +## TODO +- [ ] 支持桌面端 +- [ ] 支持安卓端、Tv端 +- [ ] 支持更多配置功能 +- [ ] AI探索 + +## 🚨 免责声明 + +1. 本项目是一个开源的视频播放器,仅供个人合法地学习和研究使用,严禁将其用于任何商业、违法或不当用途,否则由此产生的一切后果由用户自行承担。 +2. 本项目不内置任何视频源,也不针对任何特定内容提供源,用户应自行判断视频源的合法性并承担相应责任,开发者对用户获取的的任何内容不承担任何责任。 +3. 用户在使用本项目时,必须完全遵守所在地区的法律法规,严禁将本项目服务用于任何非法用途,如传播违禁信息、侵犯他人知识版权、破坏网络安全等,否则由此产生的一切后果由用户自行承担。 +4. 用户使用本项目所产生的任何风险或损失(包括但不限于:系统故障、隐私泄露等),开发者概不负责。用户应明确认知上述风险并自行防范。 +5. 未尽事宜,均依照用户所在地区相关法律法规的规定执行。如本声明与当地法律法规存在冲突,应以法律法规为准。 +6. 用户使用本项目即视为已阅读并同意本声明全部内容。开发者保留随时修订本声明的权利。本声明的最终解释权归本项目开发者所有。 \ No newline at end of file diff --git a/apps/frontend/app/category/index.module.scss b/apps/frontend/app/category/index.module.scss new file mode 100644 index 0000000..0992af5 --- /dev/null +++ b/apps/frontend/app/category/index.module.scss @@ -0,0 +1,60 @@ +.vod-next-category { + height: 100%; + width: 100%; + + &-filters { + background: var(--color-bg-container-alpha); + border-radius: 12px; + border: 1px solid var(--color-border); + margin-bottom: 24px; + overflow: hidden; + } + + &-filter { + padding: 16px 20px; + display: flex; + gap: 12px; + align-items: flex-start; + border-bottom: 1px solid var(--color-border); + + &:last-child { + border-bottom: none; + } + } + + &-label { + flex-shrink: 0; + color: var(--color-text-tertiary); + font-size: 13px; + min-width: 48px; + padding-top: 4px; + } + + &-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &-option { + padding: 4px 12px; + border-radius: 6px; + font-size: 13px; + cursor: pointer; + transition: all 0.2s; + color: var(--color-text-secondary); + background: var(--color-bg-elevated-alpha); + border: 1px solid transparent; + + &:hover { + color: var(--color-text); + background: var(--color-bg-elevated-hover); + } + + &.active { + color: var(--color-primary); + background: var(--color-primary-alpha-low); + border-color: var(--color-primary-alpha-medium); + } + } +} diff --git a/apps/frontend/app/category/page.tsx b/apps/frontend/app/category/page.tsx new file mode 100644 index 0000000..a7f1d02 --- /dev/null +++ b/apps/frontend/app/category/page.tsx @@ -0,0 +1,144 @@ +'use client'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect, useMemo, useState } from 'react'; +import store from 'store2'; + +import styles from './index.module.scss'; + +import { Loading } from '@/components/ui/Loading'; +import VodList from '@/components/video/VodList'; +import { CategoryVodData, HomeData } from '@/lib/types'; +import { categoryApi, CategoryParams } from '@/services'; + +const CategoryPage = () => { + const [categoryList, setCategoryList] = useState([]); + const [loading, setLoading] = useState(true); + + const searchParams = useSearchParams(); + const router = useRouter(); + + const id = searchParams.get('id') || ''; + const name = searchParams.get('name') || ''; + const site = searchParams.get('site') || ''; + const [filters, setFilters] = useState(); + + const getCategory = async (id: string | number, filters?: CategoryParams['filters']) => { + setLoading(true); + try { + const res = await categoryApi(site, { + id: id, + page: 1, + limit: 30, + filters + }); + const { code, data } = res; + if (code === 0) { + setCategoryList( + data.map((item) => { + return { + ...item, + type_name: name, + type_id: id + }; + }) + ); + } else { + setCategoryList([]); + } + } catch (error) { + console.log(error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + if (typeof site !== 'string' || !site) { + router.push('/home'); + } + }, [site]); + + useEffect(() => { + if (typeof id === 'string' && id) { + getCategory(id, filters); + } + }, [filters, id]); + + const currentData: HomeData | undefined = useMemo(() => { + const homeData: HomeData[] = store.get('vod_next_home_data') || []; + return homeData.find((item) => String(item.type_id) === id); + }, [id]); + + const typeMap: any = { + class: '分类', + area: '地区', + lang: '语言', + year: '年份', + letter: '首字母', + order: '排序' + }; + + const handleFilterChange = (type: string, value: string) => { + const newFilters = { ...filters } as any; + if (value === '') { + delete newFilters[type]; + } else { + newFilters[type] = value; + } + setFilters(newFilters); + }; + + return ( +
+ {loading ? ( + + ) : ( + <> + {currentData?.filters?.length ? ( +
+ {currentData.filters.map((item) => { + return ( +
+
{typeMap[item.type]}
+
+ {item.children.map((cItem) => { + return ( +
{ + handleFilterChange(item.type, cItem.label === '全部' ? '' : cItem.value); + }} + > + {cItem.label} +
+ ); + })} +
+
+ ); + })} +
+ ) : null} + + { + router.push(`/detail?id=${encodeURIComponent(vod.vod_id as string)}&site=${site}`); + }} + > + + )} +
+ ); +}; + +const SuspenseCategoryPage = () => { + return ( + }> + + + ); +}; + +export default SuspenseCategoryPage; diff --git a/apps/frontend/app/detail/index.module.scss b/apps/frontend/app/detail/index.module.scss new file mode 100644 index 0000000..d2ec2d1 --- /dev/null +++ b/apps/frontend/app/detail/index.module.scss @@ -0,0 +1,79 @@ +.vod-next-detail { + height: 100%; + width: 100%; + + &-player { + background: var(--color-bg-container); + border-radius: 16px; + overflow: hidden; + border: 1px solid var(--color-border); + } + + &-playlist { + background: var(--color-bg-container-alpha); + border-radius: 16px; + border: 1px solid var(--color-border); + padding: 20px; + } + + &-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; + } + + &-title { + font-size: 18px; + font-weight: 600; + color: var(--color-text); + } + + &-source { + font-size: 13px; + color: var(--color-text-tertiary); + } + + &-episodes { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 300px; + overflow-y: auto; + } + + &-episode { + flex: 0 0 calc(33.33% - 6px); + padding: 10px 12px; + background: var(--color-bg-elevated-alpha); + border-radius: 8px; + font-size: 13px; + color: var(--color-text-secondary); + cursor: pointer; + transition: all 0.2s; + text-align: center; + border: 1px solid transparent; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + &:hover { + color: var(--color-text); + background: var(--color-bg-elevated-hover); + } + + &.active { + color: var(--color-primary); + background: var(--color-primary-alpha-low); + border-color: var(--color-primary-alpha-medium); + } + } + + &-info { + background: var(--color-bg-container-alpha); + border-radius: 16px; + border: 1px solid var(--color-border); + padding: 20px; + margin-top: 24px; + } +} diff --git a/apps/frontend/app/detail/page.tsx b/apps/frontend/app/detail/page.tsx new file mode 100644 index 0000000..ebbdcb7 --- /dev/null +++ b/apps/frontend/app/detail/page.tsx @@ -0,0 +1,192 @@ +'use client'; +import { App, Descriptions, Flex, Select, Typography } from 'antd'; +import { includes } from 'lodash'; +import dynamic from 'next/dynamic'; +import { useRouter, useSearchParams } from 'next/navigation'; +import React, { Suspense, useEffect, useMemo, useState } from 'react'; + +import styles from './index.module.scss'; + +import { Loading } from '@/components/ui/Loading'; +import { PalyerProps } from '@/components/video/VodPalyer'; +import useIsMobile from '@/lib/hooks/useIsMobile'; +import { DetailData, VodPlayList } from '@/lib/types'; +import { detailApi, playApi } from '@/services'; + +const { Paragraph } = Typography; + +const DynamicPalyerWithNoSSR = dynamic(() => import('@/components/video/VodPalyer'), { ssr: false }); + +const DetailPage: React.FC = () => { + const searchParams = useSearchParams(); + const router = useRouter(); + const id = searchParams.get('id') || ''; + const site = searchParams.get('site') || ''; + const { isMobile } = useIsMobile(); + + const [movieDetail, setMovieDetail] = useState(); + const [activePlayList, setActivePlayList] = useState(); + const [activeUrl, setActiveUrl] = useState(''); + + const { message } = App.useApp(); + + const [playerUrl, setPlayerUrl] = useState(''); + + const playerShowType = useMemo(() => { + let showType: PalyerProps['showType'] = 'iframe'; + if (includes(playerUrl, 'm3u8')) { + showType = 'xgplayer'; + } + if (includes(playerUrl, 'mp4')) { + showType = 'xgplayer'; + } + + return showType; + }, [playerUrl]); + + const handlePlay = async (url: string, parse_urls: string[]) => { + try { + const res = await playApi(site as string, { + url, + parse_urls + }); + const { data, code } = res; + if (code === 0 && data.length > 0) { + setPlayerUrl(data[0].play_url); + } else { + message.error('播放失败, 清尝试更换播放线路'); + } + } catch (error) { + console.log(error); + } + }; + + const handleDetail = async (id: string | number) => { + try { + const res = await detailApi(site as string, { + id + }); + const { code, data } = res; + if (code === 0 && data.length > 0) { + setMovieDetail(data[0]); + setActivePlayList(data[0].vod_play_list[0]); + setActiveUrl(data[0].vod_play_list[0].urls[0].url); + handlePlay(data[0].vod_play_list[0].urls[0].url, data[0].vod_play_list[0].parse_urls || []); + } + } catch (error) { + console.log(error); + } + }; + useEffect(() => { + if (typeof id === 'string' && id) { + // eslint-disable-next-line + handleDetail(decodeURIComponent(id)); + } + }, [id]); + + useEffect(() => { + if (typeof site !== 'string' || !site) { + router.push('/home'); + } + }, [site]); + + if (!movieDetail) { + return ; + } + + const CommonDescriptions = () => { + return ( + <> + {movieDetail?.vod_director} + {movieDetail?.vod_year} + {movieDetail?.vod_area} + + ); + }; + + return ( +
+ +
+ { + message.error(msg); + }} + showType={playerShowType} + style={{ width: '100%' }} + /> +
+ +
+
+ 选集播放 + + + + + 支持完整的 HTTP(S) URL 或以 / 开头的相对路径 + + + ), + icon: + }} + required + name="vod_hub_api" + rules={[ + { + validator: (_, value) => { + if (!value) { + return Promise.reject('请输入 API 地址'); + } + if (value.startsWith('/') || /^https?:\/\//.test(value)) { + return Promise.resolve(); + } + return Promise.reject('请输入正确的 URL 或以 / 开头的路径'); + } + } + ]} + > + + + + ), + buttons: ( + <> + + + ) + }, + { + title: '验证配置', + content: isInitialized ? ( + + { + setValue(e.target.value); + }} + prefix={ + + } + placeholder="请输入关键字后搜索" + /> + + + + +
+ {loading ? : } +
+ + ); +}; + +const VodSearch: React.FC = (props) => { + const { style, site } = props; + + const { token } = theme.useToken(); + const { isMobile } = useIsMobile(); + + const [showSearch, setShowSearch] = useState(false); + const router = useRouter(); + + useKeyPress(['meta.k'], () => { + setShowSearch(true); + }); + + useKeyPress(['ctrl.k'], () => { + setShowSearch(true); + }); + + useKeyPress(27, () => { + setShowSearch(false); + }); + + return ( +
+ {isMobile ? ( + { + setShowSearch(true); + }} + /> + ) : ( + { + setShowSearch(true); + }} + prefix={ + + } + suffix={ + +
+
K
+
+ } + placeholder="搜索" + style={{ ...style, width: 200 }} + /> + )} + + {showSearch && isMobile && ( +
+ { + setShowSearch(false); + }} + onItemClick={(vod) => { + setShowSearch(false); + router.push(`/detail?id=${encodeURIComponent(vod.vod_id as string)}&site=${site}`); + }} + style={{ + height: '100%', + overflowY: 'auto' + }} + > +
+ )} + + {showSearch && !isMobile && ( + + { + setShowSearch(false); + }} + onItemClick={(vod) => { + setShowSearch(false); + router.push(`/detail?id=${encodeURIComponent(vod.vod_id as string)}&site=${site}`); + }} + > + + )} +
+ ); +}; + +export default VodSearch; diff --git a/apps/frontend/components/video/VodSites/index.module.scss b/apps/frontend/components/video/VodSites/index.module.scss new file mode 100644 index 0000000..c6c68a5 --- /dev/null +++ b/apps/frontend/components/video/VodSites/index.module.scss @@ -0,0 +1,3 @@ +.vod-header-sites { + width: 110px; +} diff --git a/apps/frontend/components/video/VodSites/index.tsx b/apps/frontend/components/video/VodSites/index.tsx new file mode 100644 index 0000000..8a1070a --- /dev/null +++ b/apps/frontend/components/video/VodSites/index.tsx @@ -0,0 +1,37 @@ +import { Select } from 'antd'; +import type { SelectProps } from 'antd'; + +import styles from './index.module.scss'; + +interface VodSitesProps { + options: SelectProps['options']; + value: string; + onChange: (value: string) => void; +} + +const VodSites: React.FC = (props) => { + const { options, value, onChange } = props; + + return ( +
+ {options && options.length > 0 && ( +