Full Code of gmonarque/streamium for AI

master f37649ec8f58 cached
127 files
305.1 KB
84.6k tokens
302 symbols
1 requests
Download .txt
Showing preview only (337K chars total). Download the full file or copy to clipboard to get everything.
Repository: gmonarque/streamium
Branch: master
Commit: f37649ec8f58
Files: 127
Total size: 305.1 KB

Directory structure:
gitextract_mk17is37/

├── .env.example
├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .npmrc
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── init.sql
├── package.json
├── postcss.config.js
├── prisma/
│   └── schema.prisma
├── server.js
├── src/
│   ├── app.css
│   ├── app.d.ts
│   ├── app.html
│   ├── env.d.ts
│   ├── hooks.server.ts
│   ├── lib/
│   │   ├── components/
│   │   │   ├── Captcha.svelte
│   │   │   ├── CommentForm.svelte
│   │   │   ├── CommentList.svelte
│   │   │   ├── CommentModeration.svelte
│   │   │   ├── EmojiPicker.svelte
│   │   │   ├── EpisodeSelector.svelte
│   │   │   ├── Hero.svelte
│   │   │   ├── Image.svelte
│   │   │   ├── MediaCard.svelte
│   │   │   ├── MediaFilters.svelte
│   │   │   ├── MediaPlayer.svelte
│   │   │   ├── MentionList.svelte
│   │   │   ├── Navbar.svelte
│   │   │   ├── NextEpisode.svelte
│   │   │   ├── Pagination.svelte
│   │   │   ├── ReplyForm.svelte
│   │   │   ├── RichTextEditor.svelte
│   │   │   ├── Toast.svelte
│   │   │   ├── VideoPlayer.svelte
│   │   │   └── WatchlistButton.svelte
│   │   ├── constants/
│   │   │   └── security.ts
│   │   ├── extensions/
│   │   │   └── mention.ts
│   │   ├── index.ts
│   │   ├── server/
│   │   │   ├── admin-middleware.ts
│   │   │   ├── auth.ts
│   │   │   ├── prisma.ts
│   │   │   └── services/
│   │   │       ├── auth.ts
│   │   │       ├── captcha.test.ts
│   │   │       ├── captcha.ts
│   │   │       ├── comments.ts
│   │   │       ├── db-error.ts
│   │   │       ├── rate-limit.ts
│   │   │       └── watchlist.ts
│   │   ├── services/
│   │   │   ├── api-client.ts
│   │   │   ├── auth.ts
│   │   │   ├── captcha.ts
│   │   │   ├── comments.ts
│   │   │   ├── image.ts
│   │   │   ├── providers.ts
│   │   │   ├── rate-limit.ts
│   │   │   ├── release-type.ts
│   │   │   ├── tmdb.ts
│   │   │   └── watchlist.ts
│   │   ├── shared/
│   │   │   └── comment-validation.ts
│   │   ├── stores/
│   │   │   ├── auth.ts
│   │   │   ├── comments.ts
│   │   │   ├── filters.ts
│   │   │   ├── provider-urls.ts
│   │   │   ├── toast.ts
│   │   │   └── watchlist.ts
│   │   ├── types/
│   │   │   ├── auth.ts
│   │   │   ├── comments.ts
│   │   │   ├── filters.ts
│   │   │   ├── provider.ts
│   │   │   └── tmdb.ts
│   │   └── utils/
│   │       └── csrf.ts
│   └── routes/
│       ├── +layout.server.ts
│       ├── +layout.svelte
│       ├── +layout.ts
│       ├── +page.server.ts
│       ├── +page.svelte
│       ├── admin/
│       │   └── moderation/
│       │       ├── +page.server.ts
│       │       └── +page.svelte
│       ├── api/
│       │   ├── auth/
│       │   │   ├── login/
│       │   │   │   └── +server.ts
│       │   │   ├── logout/
│       │   │   │   └── +server.ts
│       │   │   ├── me/
│       │   │   │   └── +server.ts
│       │   │   ├── register/
│       │   │   │   └── +server.ts
│       │   │   └── reset-password/
│       │   │       ├── request/
│       │   │       │   └── +server.ts
│       │   │       └── reset/
│       │   │           └── +server.ts
│       │   ├── captcha/
│       │   │   └── +server.ts
│       │   ├── comments/
│       │   │   ├── +server.ts
│       │   │   ├── [id]/
│       │   │   │   ├── +server.ts
│       │   │   │   ├── flag/
│       │   │   │   │   └── +server.ts
│       │   │   │   └── unflag/
│       │   │   │       └── +server.ts
│       │   │   ├── flagged/
│       │   │   │   └── +server.ts
│       │   │   └── like/
│       │   │       └── +server.ts
│       │   ├── image/
│       │   │   └── [...path]/
│       │   │       └── +server.ts
│       │   ├── movies/
│       │   │   ├── +server.ts
│       │   │   └── trending/
│       │   │       └── +server.ts
│       │   ├── providers/
│       │   │   └── +server.ts
│       │   ├── release-info/
│       │   │   └── [type]/
│       │   │       └── [id]/
│       │   │           └── +server.ts
│       │   ├── tv/
│       │   │   ├── +server.ts
│       │   │   ├── [id]/
│       │   │   │   ├── season/
│       │   │   │   │   └── [season]/
│       │   │   │   │       └── +server.ts
│       │   │   │   └── seasons/
│       │   │   │       └── +server.ts
│       │   │   └── trending/
│       │   │       └── +server.ts
│       │   ├── users/
│       │   │   └── search/
│       │   │       └── +server.ts
│       │   └── watchlist/
│       │       ├── +server.ts
│       │       └── check/
│       │           └── +server.ts
│       ├── dmca/
│       │   └── +page.svelte
│       ├── login/
│       │   └── +page.svelte
│       ├── media/
│       │   └── [id]/
│       │       ├── +page.server.ts
│       │       └── +page.svelte
│       ├── movies/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       ├── register/
│       │   └── +page.svelte
│       ├── reset-password/
│       │   ├── +page.svelte
│       │   └── [token]/
│       │       └── +page.svelte
│       ├── search/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       ├── tv/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       └── watchlist/
│           ├── +page.server.ts
│           └── +page.svelte
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
└── vite.config.ts

================================================
FILE CONTENTS
================================================

================================================
FILE: .env.example
================================================
# Database
DATABASE_URL="mysql://user:password@localhost:3306/streamium"

# Authentication (REQUIRED - use a strong random secret, min 32 chars)
# Generate with: openssl rand -base64 32
JWT_SECRET="your-secure-jwt-secret-min-32-characters"

# TMDB API (REQUIRED)
TMDB_API_KEY="your-tmdb-api-key"
TMDB_API_URL="https://api.themoviedb.org/3"
TMDB_IMAGE_URL="https://image.tmdb.org/t/p"

# Streaming Providers
VIDSRC_BASE_URL="https://vidsrc.cc/v2/embed"
VIDLINK_BASE_URL="https://vidlink.pro"
MOVIES111_BASE_URL="https://111movies.com"
EMBED2_BASE_URL="https://www.2embed.cc"


================================================
FILE: .github/FUNDING.yml
================================================
ko_fi: kasterby



================================================
FILE: .gitignore
================================================
# Environment
.env
.env.*
!.env.example

# Dependencies
node_modules
package-lock.json

# Build output
.svelte-kit
build
dist

# Database
*.db
*.db-journal
prisma/dev.db
prisma/migrations

# IDE
.idea
.vscode
*.swp
*.swo

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# OS
.DS_Store
Thumbs.db

# Cache
.cache
static/image-cache/

# Testing
coverage
.nyc_output

# Temporary files
*.tmp
*.temp


================================================
FILE: .npmrc
================================================
engine-strict=true


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Streamium

Thank you for your interest in contributing to Streamium! This document provides guidelines and instructions for contributing.

## Getting Started

1. Fork the repository
2. Clone your fork: `git clone https://github.com/gmonarque/streamium.git`
3. Create a new branch: `git checkout -b feature/your-feature-name`
4. Copy `.env.example` to `.env` and configure your environment variables
5. Install dependencies: `npm install`
6. Initialize the database: `npx prisma migrate dev`
7. Start the development server: `npm run dev`

## Development Guidelines

### Code Style

- Use TypeScript for type safety
- Follow the existing code style and formatting
- Use meaningful variable and function names
- Keep functions small and focused
- Add comments only when necessary to explain complex logic

### Commit Messages

- Use clear and descriptive commit messages
- Start with a verb in present tense (e.g., "Add", "Fix", "Update")
- Reference issue numbers when applicable

Example:
```
Add password strength validation
Fix rate limiting on login endpoint
Update user profile UI components
```

### Pull Requests

1. Update your branch with the latest main branch
2. Ensure all tests pass
3. Update documentation if needed
4. Create a pull request with a clear description of changes
5. Link any related issues

### Testing

- Write tests for new features
- Ensure existing tests pass
- Test your changes in different browsers
- Check mobile responsiveness

### Security

- Follow security guidelines in SECURITY.md
- Never commit sensitive data
- Use environment variables for secrets
- Implement rate limiting for new endpoints
- Validate and sanitize all user input

## Project Structure

```
streamium/
├── src/
│   ├── lib/          # Components, services, stores
│   ├── routes/       # SvelteKit routes and API
│   └── app.html      # App template
├── prisma/
│   └── schema.prisma # Database schema
└── static/           # Static assets
```

## Need Help?

- Check existing issues and pull requests
- Read the documentation
- Ask questions in discussions
- Follow the code of conduct

## Code of Conduct

- Be respectful and inclusive
- No harassment or discrimination
- Constructive feedback only
- Follow project maintainers' decisions

Thank you for contributing to Streamium!


================================================
FILE: Dockerfile
================================================
FROM node:18-slim

WORKDIR /app

RUN apt-get update && apt-get install -y \
    openssl \
    default-mysql-client \
    && rm -rf /var/lib/apt/lists/*

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/

RUN pnpm install --frozen-lockfile

COPY . .

RUN pnpm prisma generate
RUN pnpm run build
RUN pnpm prune --prod

EXPOSE 5173

ENV NODE_ENV=production

CMD ["node", "build"]


================================================
FILE: LICENSE
================================================
MIT License

Copyright (c) 2024 https://github.com/gmonarque

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: README.md
================================================
# Streamium

A SvelteKit streaming UI that embeds content from third-party providers and uses TMDB for movie/TV metadata.

<p>
  <img src="./screenshots/screenshot1.png" width="320" alt="Streamium screenshot 1" />
  <img src="./screenshots/screenshot2.png" width="320" alt="Streamium screenshot 2" />
  <img src="./screenshots/screenshot3.png" width="320" alt="Streamium screenshot 3" />
</p>

## Features
- TMDB-powered catalog, search, and filters
- Multiple embed providers (VidSrc, VidLink, 111Movies, 2Embed)
- Auth, watchlist, and comments with moderation
- Server-rendered UI with image proxying
- Basic security controls (CSRF, captcha, rate limiting, CSP)

## Tech Stack
- SvelteKit + TypeScript + Tailwind CSS
- Prisma + MySQL
- JWT auth, Zod validation

## Quickstart (Local)

Prereqs: Node.js 18+, pnpm, and MySQL (optional if you only browse content).

1) Configure env:
```bash
cp .env.example .env
```

2) Install deps:
```bash
pnpm install
```

3) (Optional) DB setup:
```bash
pnpm prisma generate
pnpm prisma migrate dev
```

4) Run dev server:
```bash
pnpm dev
```

App runs at http://localhost:5173

## Docker

1) Set required env vars (at minimum):
- `JWT_SECRET`
- `TMDB_API_KEY`
- `MYSQL_PASSWORD`
- `MYSQL_ROOT_PASSWORD`

2) Start:
```bash
docker compose up --build
```

3) Run migrations:
```bash
docker compose exec web pnpm prisma migrate dev
```

## Environment Variables

Key entries in `.env.example`:
- `DATABASE_URL`
- `JWT_SECRET`
- `TMDB_API_KEY`
- `TMDB_API_URL`
- Provider URLs: `VIDSRC_BASE_URL`, `VIDLINK_BASE_URL`, `MOVIES111_BASE_URL`, `EMBED2_BASE_URL`

## Scripts
- `pnpm dev` – start dev server
- `pnpm build` – production build
- `pnpm test` – run tests

## License
MIT

## Legal Disclaimer
This project embeds third‑party content and does not host media files. Use it only with properly licensed content and in compliance with applicable laws. The software is provided “as is” without warranty.


================================================
FILE: SECURITY.md
================================================
# Security Policy

## Reporting a Vulnerability

If you discover a security vulnerability within Streamium, please send a private message on github. All security vulnerabilities will be promptly addressed.

Please include the following information in your report:
- Description of the vulnerability
- Steps to reproduce the issue
- Potential impact
- Any suggested fixes (if applicable)

## Security Measures

Streamium implements several security measures:

1. **Authentication**
   - JWT-based authentication
   - Secure password hashing
   - Rate limiting on login attempts
   - Password reset with secure tokens

2. **Data Protection**
   - Input validation and sanitization
   - XSS protection
   - CSRF protection
   - SQL injection prevention through Prisma ORM

3. **API Security**
   - Rate limiting on sensitive endpoints
   - Request validation
   - Secure error handling

## Development Guidelines

When contributing to Streamium, please ensure:

1. All passwords are hashed using bcrypt
2. Sensitive data is never logged
3. Environment variables are used for secrets
4. Input is properly validated and sanitized
5. Rate limiting is implemented on sensitive endpoints
6. Error messages don't leak sensitive information

## Known Issues

There are currently no known security issues. Check this section for updates on security-related issues and their status.


================================================
FILE: docker-compose.yml
================================================
services:
  web:
    build: .
    ports:
      - "5173:5173"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      - DATABASE_URL=${DATABASE_URL:-mysql://root:rootpassword@db:3306/streamium}
      - JWT_SECRET=${JWT_SECRET:?JWT_SECRET is required}
      - TMDB_API_KEY=${TMDB_API_KEY:?TMDB_API_KEY is required}
      - TMDB_API_URL=https://api.themoviedb.org/3
      - TMDB_IMAGE_URL=https://image.tmdb.org/t/p
      - VIDSRC_BASE_URL=https://vidsrc.cc/v2/embed
      - VIDSRC_PRO_BASE_URL=https://vidsrc.pro/embed
      - EMBEDSU_BASE_URL=https://embed.su/embed
      - NODE_ENV=${NODE_ENV:-development}
    depends_on:
      - db

  db:
    image: mysql:8
    ports:
      - "3307:3306"
    environment:
      - MYSQL_DATABASE=streamium
      - MYSQL_USER=${MYSQL_USER:-user}
      - MYSQL_PASSWORD=${MYSQL_PASSWORD:?MYSQL_PASSWORD is required}
      - MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD is required}
    volumes:
      - mysql_data:/var/lib/mysql
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql

volumes:
  mysql_data:


================================================
FILE: init.sql
================================================
GRANT CREATE ON *.* TO 'user'@'%';
CREATE DATABASE IF NOT EXISTS `streamium_shadow`;
GRANT ALL PRIVILEGES ON `streamium_shadow`.* TO 'user'@'%';
GRANT ALL PRIVILEGES ON `streamium`.* TO 'user'@'%';
FLUSH PRIVILEGES;


================================================
FILE: package.json
================================================
{
  "name": "streamium",
  "version": "0.0.1",
  "type": "module",
  "scripts": {
    "dev": "vite dev",
    "build": "vite build",
    "preview": "vite preview",
    "test": "vitest run",
    "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
    "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
    "start": "node server.js"
  },
  "devDependencies": {
    "@sveltejs/adapter-auto": "^3.3.1",
    "@sveltejs/vite-plugin-svelte": "^4.0.0",
    "@types/bcryptjs": "^2.4.6",
    "@types/node": "^22.9.0",
    "autoprefixer": "^10.4.20",
    "postcss": "^8.4.49",
    "svelte-check": "^4.0.0",
    "tailwindcss": "^3.4.14",
    "typescript": "^5.0.0",
    "vite": "^5.4.11",
    "vitest": "^4.0.18"
  },
  "dependencies": {
    "@emoji-mart/data": "^1.2.1",
    "@emoji-mart/react": "^1.1.1",
    "@melt-ui/pp": "^0.3.2",
    "@prisma/client": "5.22.0",
    "@sveltejs/adapter-node": "^5.2.9",
    "@sveltejs/kit": "^2.8.0",
    "@tailwindcss/aspect-ratio": "^0.4.2",
    "@tailwindcss/forms": "^0.5.9",
    "@tailwindcss/typography": "^0.5.15",
    "@tiptap/core": "^2.9.1",
    "@tiptap/extension-link": "^2.9.1",
    "@tiptap/starter-kit": "^2.9.1",
    "@tiptap/suggestion": "^2.9.1",
    "@types/dompurify": "^3.0.5",
    "@types/jsonwebtoken": "^9.0.7",
    "@types/sharp": "^0.31.1",
    "bcryptjs": "^3.0.3",
    "date-fns": "^4.1.0",
    "dompurify": "^3.2.0",
    "emoji-mart": "^5.6.0",
    "isomorphic-dompurify": "^2.17.0",
    "jsonwebtoken": "^9.0.2",
    "mysql2": "^3.11.4",
    "prisma": "5.22.0",
    "sharp": "^0.33.5",
    "svelte": "^5.0.0",
    "svelte-preprocess": "^6.0.3",
    "svelte-sequential-preprocessor": "^2.0.2",
    "zod": "^3.23.8"
  },
  "engines": {
    "node": ">=18.0.0"
  },
  "pnpm": {
    "overrides": {
      "cookie": "0.7.2"
    }
  }
}


================================================
FILE: postcss.config.js
================================================
export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
};


================================================
FILE: prisma/schema.prisma
================================================
generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

model User {
  id              Int           @id @default(autoincrement())
  username        String        @unique
  email           String?       @unique
  passwordHash    String
  resetToken      String?
  resetTokenExp   DateTime?
  isAdmin         Boolean       @default(false)
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt
  watchlist       Watchlist[]
  comments        Comment[]
  commentLikes    CommentLike[]

  @@map("users")
}

model Watchlist {
  id          Int      @id @default(autoincrement())
  userId      Int
  mediaId     Int
  mediaType   String
  title       String
  posterPath  String?
  voteAverage Float    @default(0)
  addedAt     DateTime @default(now())
  user        User     @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@unique([userId, mediaId, mediaType])
  @@map("watchlist")
}

model Comment {
  id          Int           @id @default(autoincrement())
  userId      Int
  mediaId     Int
  mediaType   String
  season      Int?
  episode     Int?
  content     String       @db.Text
  createdAt   DateTime     @default(now())
  updatedAt   DateTime     @updatedAt
  parentId    Int?
  flagged     Boolean      @default(false)
  flagReason  String?      @db.Text
  flaggedAt   DateTime?
  user        User         @relation(fields: [userId], references: [id], onDelete: Cascade)
  parent      Comment?     @relation("CommentReplies", fields: [parentId], references: [id], onDelete: Cascade)
  replies     Comment[]    @relation("CommentReplies")
  likes       CommentLike[]

  @@index([mediaId, mediaType])
  @@index([mediaId, mediaType, season, episode])
  @@index([userId])
  @@index([parentId])
  @@index([flagged])
  @@map("comments")
}

model CommentLike {
  id        Int      @id @default(autoincrement())
  userId    Int
  commentId Int
  createdAt DateTime @default(now())
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  comment   Comment  @relation(fields: [commentId], references: [id], onDelete: Cascade)

  @@unique([userId, commentId])
  @@map("comment_likes")
}

model ImageCache {
  id        Int      @id @default(autoincrement())
  url       String   @unique
  path      String
  format    String
  width     Int?
  height    Int?
  quality   Int
  createdAt DateTime @default(now())
  accessedAt DateTime @default(now())

  @@index([url])
  @@map("image_cache")
}


================================================
FILE: server.js
================================================
// Production server wrapper
import('./build/index.js').catch(err => {
    console.error('Failed to import app:', err);
    process.exit(1);
});


================================================
FILE: src/app.css
================================================
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  html {
    @apply antialiased;
  }

  body {
    @apply bg-gray-900 text-white;
  }

  .sveltekit-body {
    display: contents;
  }

  /* Custom scrollbar */
  ::-webkit-scrollbar {
    @apply w-2;
  }

  ::-webkit-scrollbar-track {
    @apply bg-gray-800;
  }

  ::-webkit-scrollbar-thumb {
    @apply bg-gray-600 rounded-full hover:bg-gray-500 transition-colors;
  }
}

@layer components {
  .btn {
    @apply inline-flex items-center justify-center px-4 py-2 rounded-lg font-medium transition-colors;
  }

  .btn-primary {
    @apply bg-primary-500 text-white hover:bg-primary-600;
  }

  .btn-secondary {
    @apply bg-gray-700 text-white hover:bg-gray-600;
  }

  .input {
    @apply px-3 py-2 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent;
  }

  .card {
    @apply bg-gray-800 rounded-lg overflow-hidden;
  }

  .card-hover {
    @apply transition-transform hover:scale-105;
  }
}

/* Loading animation */
@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.5;
  }
}

.animate-pulse {
  animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}

/* Fade animations */
.fade-enter {
  opacity: 0;
}

.fade-enter-active {
  opacity: 1;
  transition: opacity 200ms ease-in;
}

.fade-exit {
  opacity: 1;
}

.fade-exit-active {
  opacity: 0;
  transition: opacity 200ms ease-out;
}

/* Hero section gradient */
.hero-gradient {
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(17, 24, 39, 0.7) 50%,
    rgba(17, 24, 39, 1) 100%
  );
}

/* Media card hover effects */
.media-card-overlay {
  @apply absolute inset-0 bg-black bg-opacity-0 transition-all duration-300;
}

.media-card:hover .media-card-overlay {
  @apply bg-opacity-60;
}

.media-card-content {
  @apply absolute inset-0 flex flex-col justify-end p-4 opacity-0 transition-opacity duration-300;
}

.media-card:hover .media-card-content {
  @apply opacity-100;
}

/* Toast notifications */
.toast {
  @apply fixed bottom-4 right-4 px-4 py-2 rounded-lg shadow-lg transform transition-all duration-300;
}

.toast-enter {
  @apply translate-y-full opacity-0;
}

.toast-enter-active {
  @apply translate-y-0 opacity-100;
}

.toast-exit {
  @apply translate-y-0 opacity-100;
}

.toast-exit-active {
  @apply translate-y-full opacity-0;
}


================================================
FILE: src/app.d.ts
================================================
/// <reference types="@sveltejs/kit" />

// See https://kit.svelte.dev/docs/types#app
declare global {
  namespace App {
    interface Error {
      message: string;
      code?: string;
    }
    interface Locals {
      user: {
        id: number;
        username: string;
        email: string;
        isAdmin: boolean;
      } | null;
    }
    interface PageData {
      user: {
        id: number;
        username: string;
        email: string;
        isAdmin: boolean;
      } | null;
    }
    interface Platform {}
  }

  namespace NodeJS {
    interface ProcessEnv {
      TMDB_API_KEY: string;
      DATABASE_URL: string;
      JWT_SECRET: string;
    }
  }
}

export {};


================================================
FILE: src/app.html
================================================
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%sveltekit.assets%/favicon.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div class="sveltekit-body">%sveltekit.body%</div>
  </body>
</html>


================================================
FILE: src/env.d.ts
================================================
/// <reference types="vite/client" />

interface ImportMetaEnv {
  readonly VITE_TMDB_API_KEY: string;
  readonly TMDB_API_KEY: string;
  readonly JWT_SECRET: string;
  readonly DATABASE_URL: string;
  readonly SMTP_HOST: string;
  readonly SMTP_PORT: string;
  readonly SMTP_USER: string;
  readonly SMTP_PASS: string;
  readonly SMTP_FROM: string;
  readonly NODE_ENV: "development" | "production";
  readonly VIDSRC_BASE_URL: string;
  readonly VIDLINK_BASE_URL: string;
  readonly MOVIES111_BASE_URL: string;
  readonly EMBED2_BASE_URL: string;
}

interface ImportMeta {
  readonly env: ImportMetaEnv;
}

declare module "$env/static/private" {
  export const JWT_SECRET: string;
  export const DATABASE_URL: string;
  export const TMDB_API_KEY: string;
  export const SMTP_HOST: string;
  export const SMTP_PORT: string;
  export const SMTP_USER: string;
  export const SMTP_PASS: string;
  export const SMTP_FROM: string;
  export const NODE_ENV: "development" | "production";
  export const VIDSRC_BASE_URL: string;
  export const VIDLINK_BASE_URL: string;
  export const MOVIES111_BASE_URL: string;
  export const EMBED2_BASE_URL: string;
}

declare module "$env/static/public" {
  export const PUBLIC_TMDB_API_KEY: string;
}


================================================
FILE: src/hooks.server.ts
================================================
import type { Handle } from "@sveltejs/kit";
import { getSession, createCsrfToken } from "$lib/server/auth";
import { prisma } from "$lib/server/prisma";
import { isDatabaseConnectionError } from "$lib/server/services/db-error";
import { dev } from "$app/environment";
import crypto from "node:crypto";
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "$lib/constants/security";

const ADMIN_RATE_LIMIT = 100;
const ADMIN_RATE_WINDOW = 5 * 60 * 1000;
const adminRateLimits = new Map<string, { count: number; firstAttempt: number }>();
const CSRF_MAX_AGE = 60 * 60 * 24 * 7;

function checkAdminRateLimit(ip: string): boolean {
  const now = Date.now();
  const limit = adminRateLimits.get(ip);

  if (!limit) {
    adminRateLimits.set(ip, { count: 1, firstAttempt: now });
    return true;
  }

  if (now - limit.firstAttempt >= ADMIN_RATE_WINDOW) {
    adminRateLimits.set(ip, { count: 1, firstAttempt: now });
    return true;
  }

  if (limit.count >= ADMIN_RATE_LIMIT) {
    return false;
  }

  limit.count++;
  adminRateLimits.set(ip, limit);
  return true;
}

setInterval(() => {
  const now = Date.now();
  for (const [key, limit] of adminRateLimits.entries()) {
    if (now - limit.firstAttempt >= ADMIN_RATE_WINDOW) {
      adminRateLimits.delete(key);
    }
  }
}, ADMIN_RATE_WINDOW);

function setSecurityHeaders(response: Response, nonce: string): void {
  const isDev = dev;

  response.headers.set('X-Frame-Options', 'DENY');
  response.headers.set('X-Content-Type-Options', 'nosniff');
  response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
  response.headers.set(
    'Content-Security-Policy',
    "default-src 'self'; " +
    (isDev
      ? "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
      : `script-src 'self' 'nonce-${nonce}'; `) +
    (isDev
      ? "style-src 'self' 'unsafe-inline'; "
      : `style-src 'self' 'nonce-${nonce}'; `) +
    "img-src 'self' data: https:; " +
    "font-src 'self' data:; " +
    "frame-src 'self' " +
    "https://vidsrc.cc/ https://*.vidsrc.cc/ " +
    "https://vidplay.site/ https://*.vidplay.site/ " +
    "https://vidplay.online/ https://*.vidplay.online/ " +
    "https://vidlink.pro/ https://*.vidlink.pro/ " +
    "https://111movies.com/ https://*.111movies.com/ " +
    "https://2embed.cc/ https://*.2embed.cc/;"
  );
}

export const handle: Handle = async ({ event, resolve }) => {
  const nonce = crypto.randomBytes(16).toString("base64");
  const isAdminRoute = event.url.pathname.startsWith('/admin');
  const method = event.request.method.toUpperCase();
  const hasSessionCookie = Boolean(event.cookies.get("session"));

  if (!["GET", "HEAD", "OPTIONS"].includes(method) && hasSessionCookie) {
    const origin = event.request.headers.get("origin");
    if (origin && origin !== event.url.origin) {
      const response = new Response("Cross-origin requests not allowed", { status: 403 });
      setSecurityHeaders(response, nonce);
      return response;
    }

    const csrfHeader = event.request.headers.get(CSRF_HEADER_NAME);
    const csrfCookie = event.cookies.get(CSRF_COOKIE_NAME);
    if (!csrfHeader || !csrfCookie || csrfHeader !== csrfCookie) {
      const response = new Response("Invalid CSRF token", { status: 403 });
      setSecurityHeaders(response, nonce);
      return response;
    }
  }

  // Authenticate user BEFORE resolving the request
  try {
    const session = await getSession(event.cookies);

    if (session?.userId) {
      const user = await prisma.user.findUnique({
        where: {
          id: session.userId,
        },
        select: {
          id: true,
          username: true,
          email: true,
          isAdmin: true,
        },
      });

      if (user) {
        event.locals.user = {
          id: user.id,
          username: user.username,
          email: user.email || '',
          isAdmin: user.isAdmin,
        };

        if (isAdminRoute) {
          if (!user.isAdmin) {
            const response = new Response('Unauthorized', { status: 403 });
            setSecurityHeaders(response, nonce);
            return response;
          }

          const clientIp = event.getClientAddress();
          if (!checkAdminRateLimit(clientIp)) {
            const response = new Response('Too Many Requests', { status: 429 });
            setSecurityHeaders(response, nonce);
            return response;
          }
        }
        if (!event.cookies.get(CSRF_COOKIE_NAME)) {
          const csrfToken = createCsrfToken();
          event.cookies.set(CSRF_COOKIE_NAME, csrfToken, {
            path: "/",
            sameSite: "strict",
            secure: !dev,
            httpOnly: false,
            maxAge: CSRF_MAX_AGE,
          });
        }
      } else {
        event.cookies.delete("session", { path: "/" });

        if (isAdminRoute) {
          const response = new Response('Unauthorized', { status: 403 });
          setSecurityHeaders(response, nonce);
          return response;
        }
      }
    } else if (isAdminRoute) {
      const response = new Response('Unauthorized', { status: 403 });
      setSecurityHeaders(response, nonce);
      return response;
    }
  } catch (error) {
    // Handle database connection errors gracefully
    if (isDatabaseConnectionError(error)) {
      console.error("Database unavailable during session validation");
      // Don't delete session cookie - DB might come back
      // For admin routes, return 503; for others, continue without auth
      if (isAdminRoute) {
        const response = new Response('Service temporarily unavailable', { status: 503 });
        setSecurityHeaders(response, nonce);
        return response;
      }
      // Continue without user context for non-admin routes
    } else {
      event.cookies.delete("session", { path: "/" });
      console.error("Session validation error:", error);

      if (isAdminRoute) {
        const response = new Response('Unauthorized', { status: 403 });
        setSecurityHeaders(response, nonce);
        return response;
      }
    }
  }

  // Only resolve AFTER auth checks pass
  const response = await resolve(event, {
    transformPageChunk: ({ html }) =>
      html
        .replace(/<script(?![^>]*nonce=)/g, `<script nonce=\"${nonce}\"`)
        .replace(/<style(?![^>]*nonce=)/g, `<style nonce=\"${nonce}\"`),
  });
  setSecurityHeaders(response, nonce);

  return response;
};


================================================
FILE: src/lib/components/Captcha.svelte
================================================
<script lang="ts">
  import { createEventDispatcher } from 'svelte';
  import { onMount } from 'svelte';
  import { csrfFetch } from '$lib/utils/csrf';

  export let required = true;

  const dispatch = createEventDispatcher<{ verify: { valid: boolean; captchaId: string; answer: string } }>();

  let captchaId = '';
  let captchaText = '';
  let userInput = '';
  let canvas: HTMLCanvasElement;
  let loading = false;
  let verified = false;

  async function fetchCaptcha() {
    loading = true;
    verified = false;
    userInput = '';

    try {
      const response = await fetch('/api/captcha');
      if (!response.ok) {
        throw new Error('Failed to fetch captcha');
      }
      const data = await response.json();
      captchaId = data.id;
      captchaText = data.text;
      renderCaptcha();
    } catch (error) {
      console.error('Error fetching captcha:', error);
    } finally {
      loading = false;
    }
  }

  function renderCaptcha() {
    const ctx = canvas?.getContext('2d');
    if (!ctx || !captchaText) return;

    ctx.clearRect(0, 0, canvas.width, canvas.height);

    const gradient = ctx.createRadialGradient(
      canvas.width/2, canvas.height/2, 0,
      canvas.width/2, canvas.height/2, canvas.width/2
    );
    gradient.addColorStop(0, '#1a1e2d');
    gradient.addColorStop(0.5, '#1f2937');
    gradient.addColorStop(1, '#1a1e2d');
    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    for (let i = 0; i < 3; i++) {
      ctx.beginPath();
      const startY = Math.random() * canvas.height;
      const endY = Math.random() * canvas.height;
      const controlY = Math.random() * canvas.height;

      ctx.moveTo(0, startY);
      ctx.quadraticCurveTo(
        canvas.width/2, controlY,
        canvas.width, endY
      );

      ctx.strokeStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.1)`;
      ctx.lineWidth = 15;
      ctx.stroke();
    }

    for (let i = 0; i < 150; i++) {
      ctx.beginPath();
      ctx.arc(
        Math.random() * canvas.width,
        Math.random() * canvas.height,
        Math.random() * 2,
        0,
        Math.PI * 2
      );
      ctx.fillStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.3)`;
      ctx.fill();
    }

    const chars_array = captchaText.split('');
    const char_width = canvas.width / (chars_array.length + 1);

    chars_array.forEach((char, i) => {
      ctx.save();
      const x = char_width * (i + 0.8) + (Math.random() - 0.5) * 15;
      const y = canvas.height / 2 + (Math.random() - 0.5) * 20;

      ctx.translate(x, y);
      ctx.rotate((Math.random() - 0.5) * 0.8);

      const fonts = ['Arial Black', 'Impact', 'Verdana', 'Times New Roman'];
      const randomFont = fonts[Math.floor(Math.random() * fonts.length)];
      const fontSize = Math.floor(Math.random() * 10) + 28;
      ctx.font = `bold ${fontSize}px ${randomFont}`;

      for (let j = 0; j < 2; j++) {
        ctx.shadowColor = `rgba(0, 0, 0, ${0.2 + Math.random() * 0.3})`;
        ctx.shadowBlur = 4 + Math.random() * 4;
        ctx.shadowOffsetX = (Math.random() - 0.5) * 6;
        ctx.shadowOffsetY = (Math.random() - 0.5) * 6;
        ctx.fillStyle = `rgba(${Math.random() * 100}, ${Math.random() * 100}, ${Math.random() * 100}, 0.3)`;
        ctx.fillText(char, 0, 0);
      }

      ctx.restore();
    });

    chars_array.forEach((char, i) => {
      ctx.save();
      const x = char_width * (i + 0.8) + (Math.random() - 0.5) * 15;
      const y = canvas.height / 2 + (Math.random() - 0.5) * 20;

      ctx.translate(x, y);
      ctx.rotate((Math.random() - 0.5) * 0.8);

      const fonts = ['Arial Black', 'Impact', 'Verdana', 'Times New Roman'];
      const randomFont = fonts[Math.floor(Math.random() * fonts.length)];
      const fontSize = Math.floor(Math.random() * 10) + 28;
      ctx.font = `bold ${fontSize}px ${randomFont}`;

      const brightness = Math.random() * 100 + 120;
      ctx.fillStyle = `rgb(${brightness}, ${brightness}, ${brightness})`;

      ctx.transform(1, Math.random() * 0.3 - 0.15, Math.random() * 0.3 - 0.15, 1, 0, 0);
      ctx.fillText(char, 0, 0);

      if (Math.random() > 0.5) {
        ctx.beginPath();
        ctx.moveTo(-fontSize/2, -fontSize/2);
        ctx.lineTo(fontSize/2, fontSize/2);
        ctx.strokeStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.5)`;
        ctx.lineWidth = 1 + Math.random();
        ctx.stroke();
      }

      ctx.restore();
    });

    for (let i = 0; i < 8; i++) {
      ctx.beginPath();
      const startX = Math.random() * canvas.width;
      const startY = 0;
      const endX = Math.random() * canvas.width;
      const endY = canvas.height;

      ctx.moveTo(startX, startY);
      ctx.lineTo(endX, endY);
      ctx.strokeStyle = `rgba(${Math.random() * 255}, ${Math.random() * 255}, ${Math.random() * 255}, 0.3)`;
      ctx.lineWidth = 1 + Math.random();
      ctx.stroke();
    }

    for (let i = 0; i < 3; i++) {
      ctx.beginPath();
      let x = 0;
      let y = Math.random() * canvas.height;
      ctx.moveTo(x, y);

      while (x < canvas.width) {
        x += 10;
        y = y + Math.sin(x * 0.05) * 15;
        ctx.lineTo(x, y);
      }

      ctx.strokeStyle = `rgba(255, 255, 255, 0.1)`;
      ctx.lineWidth = 2;
      ctx.stroke();
    }
  }

  async function verifyCaptcha() {
    if (!userInput || !captchaId) {
      dispatch('verify', { valid: false, captchaId: '', answer: '' });
      return;
    }

    // Dispatch with data for server-side validation during form submission
    // We also do a preliminary server check here
    try {
      const response = await csrfFetch('/api/captcha', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ id: captchaId, answer: userInput }),
      });

      const result = await response.json();
      verified = result.valid;

      dispatch('verify', {
        valid: result.valid,
        captchaId,
        answer: userInput
      });

      if (!result.valid) {
        // Get a new captcha on failure
        await fetchCaptcha();
      }
    } catch {
      dispatch('verify', { valid: false, captchaId: '', answer: '' });
      await fetchCaptcha();
    }
  }

  onMount(fetchCaptcha);
</script>

<div class="flex flex-col gap-4">
  <div class="flex items-center gap-4">
    <canvas
      bind:this={canvas}
      width="200"
      height="60"
      class="border border-gray-600 rounded bg-gray-700"
      aria-label="CAPTCHA image"
    ></canvas>
    <button
      type="button"
      class="p-2 rounded hover:bg-gray-700 text-gray-300 disabled:opacity-50"
      on:click={fetchCaptcha}
      disabled={loading}
      aria-label="Generate new CAPTCHA"
    >
      {#if loading}
        <svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
          <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
          <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
        </svg>
      {:else}
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
        </svg>
      {/if}
    </button>
    {#if verified}
      <svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
      </svg>
    {/if}
  </div>

  <div class="flex flex-col gap-2">
    <label for="captcha-input" class="text-sm font-medium text-gray-300">
      Enter the text shown above
      {#if required}
        <span class="text-red-500">*</span>
      {/if}
    </label>
    <input
      id="captcha-input"
      type="text"
      bind:value={userInput}
      on:blur={verifyCaptcha}
      class="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
      class:border-green-500={verified}
      {required}
      disabled={loading}
      aria-required={required}
      aria-label="CAPTCHA verification input"
    />
  </div>
</div>


================================================
FILE: src/lib/components/CommentForm.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';
  import RichTextEditor from './RichTextEditor.svelte';
  import { authStore } from '$lib/stores/auth';
  import { validateComment } from '$lib/shared/comment-validation';
  import { csrfFetch } from '$lib/utils/csrf';

  export let mediaId: number;
  export let mediaType: 'movie' | 'tv';
  export let season: number | undefined = undefined;
  export let episode: number | undefined = undefined;
  export let onCommentAdded: (comment: {
    id: number;
    content: string;
    createdAt: string;
    user: {
      id: number;
      username: string;
    };
    replies: never[];
    _count: { likes: number };
    isLiked: boolean;
    flagged: boolean;
    parentId: null;
  }) => void;

  let content = '<p></p>';
  let isSubmitting = false;
  let error = '';
  let charCount = 0;
  let isValid = false;
  let editor: RichTextEditor;
  const MAX_CHARS = 1000;

  $: {
    if (content === '<p></p>') {
      isValid = false;
      error = '';
      charCount = 0;
    } else {
      const validation = validateComment(content);
      isValid = validation.isValid && charCount <= MAX_CHARS;
      if (!validation.isValid && validation.error) {
        error = validation.error;
      } else if (charCount > MAX_CHARS) {
        error = 'Comment is too long';
      } else {
        error = '';
      }
    }
  }

  async function handleSubmit() {
    if (!isValid || isSubmitting) return;

    try {
      isSubmitting = true;
      error = '';

      const validation = validateComment(content);
      if (!validation.isValid) {
        error = validation.error || 'Invalid comment';
        return;
      }

      const response = await csrfFetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          mediaId,
          mediaType,
          content,
          season: mediaType === 'tv' ? season : undefined,
          episode: mediaType === 'tv' ? episode : undefined
        })
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Failed to post comment');
      }

      const newComment = await response.json();
      editor?.clear();
      onCommentAdded({
        ...newComment,
        user: $authStore.user!,
        replies: [],
        _count: { likes: 0 },
        isLiked: false,
        flagged: false
      });
    } catch (err) {
      if (err instanceof Error) {
        error = err.message;
      } else {
        error = 'An unexpected error occurred';
      }
    } finally {
      isSubmitting = false;
    }
  }

  function handleContentInput(event: CustomEvent<string>) {
    content = event.detail;

    const textContent = content.replace(/<[^>]*>/g, '');
    charCount = textContent.length;
  }
</script>

<div class="bg-gray-800/50 rounded-lg p-6 backdrop-blur-sm border border-gray-700/50">
  <h3 class="text-xl font-semibold mb-4">Add a Comment</h3>

  {#if $authStore.isAuthenticated}
    <form on:submit|preventDefault={handleSubmit} class="space-y-4">
      <div class="space-y-2">
        <RichTextEditor
          bind:this={editor}
          bind:content
          on:input={handleContentInput}
          class_="min-h-[120px] bg-gray-900/50"
        />

        <div class="flex justify-between items-center text-sm">
          <span class="text-gray-400">
            {charCount}/{MAX_CHARS} characters
          </span>
          {#if error}
            <span class="text-red-400">{error}</span>
          {/if}
        </div>
      </div>

      <div class="flex justify-end">
        <button
          type="submit"
          class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
          disabled={!isValid || isSubmitting}
        >
          {isSubmitting ? 'Posting...' : 'Post Comment'}
        </button>
      </div>
    </form>
  {:else}
    <div class="text-center py-6">
      <p class="text-gray-400">Please <a href="/login" class="text-blue-400 hover:underline">log in</a> to leave a comment.</p>
    </div>
  {/if}
</div>


================================================
FILE: src/lib/components/CommentList.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';
  import { browser } from '$app/environment';
  import { authStore } from '$lib/stores/auth';
  import { formatDistanceToNow } from 'date-fns';
  import DOMPurify from 'isomorphic-dompurify';
  import ReplyForm from './ReplyForm.svelte';
  import CommentForm from './CommentForm.svelte';
  import { csrfFetch } from '$lib/utils/csrf';

  export let mediaId: number;
  export let mediaType: 'movie' | 'tv';
  export let season: number | undefined = undefined;
  export let episode: number | undefined = undefined;

  interface User {
    id: number;
    username: string;
  }

  interface CommentLike {
    id: number;
    userId: number;
    commentId: number;
  }

  interface Comment {
    id: number;
    content: string;
    createdAt: string;
    user: User;
    replies: Comment[];
    _count: {
      likes: number;
    };
    isLiked: boolean;
    flagged: boolean;
    parentId: number | null;
  }

  type SortOption = 'recent' | 'likes';
  let sortBy: SortOption = 'recent';
  let comments: Comment[] = [];
  let isLoading = true;
  let error = '';
  let page = 1;
  let totalPages = 1;
  let replyingToId: number | null = null;
  let mounted = false;

  $: sortedComments = [...comments].sort((a, b) => {
    if (sortBy === 'likes') {
      return b._count.likes - a._count.likes;
    }
    return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
  });

  function formatDate(date: string) {
    try {
      return formatDistanceToNow(new Date(date), { addSuffix: true });
    } catch (e) {
      return 'just now';
    }
  }

  function sanitizeContent(content: string): string {
    return DOMPurify.sanitize(content, {
      ALLOWED_TAGS: ['p', 'b', 'i', 'em', 'strong', 'br'],
      ALLOWED_ATTR: []
    });
  }

  async function loadComments() {
    if (!browser || !mounted) return;

    try {
      isLoading = true;
      error = '';
      let url = `/api/comments?mediaId=${mediaId}&mediaType=${mediaType}&page=${page}`;
      if (mediaType === 'tv' && season !== undefined && episode !== undefined) {
        url += `&season=${season}&episode=${episode}`;
      }
      const response = await fetch(url);

      if (!response.ok) {
        const errorData = await response.json().catch(() => null);
        throw new Error(errorData?.message || `Error ${response.status}: ${response.statusText}`);
      }

      const data = await response.json();

      if (!data || !Array.isArray(data.comments)) {
        throw new Error('Invalid response format from server');
      }

      comments = data.comments;
      totalPages = data.totalPages;
    } catch (e) {
      console.error('Error loading comments:', e);
      error = e instanceof Error ? e.message : 'Failed to load comments';
    } finally {
      isLoading = false;
    }
  }

  async function handleLike(commentId: number) {
    if (!$authStore.isAuthenticated) return;

    let comment = comments.find(c => c.id === commentId);
    if (!comment) {
      for (const topComment of comments) {
        comment = topComment.replies.find(r => r.id === commentId);
        if (comment) break;
      }
    }
    if (!comment) return;

    comment.isLiked = !comment.isLiked;
    comment._count.likes += comment.isLiked ? 1 : -1;
    comments = [...comments];

    try {
      const response = await csrfFetch('/api/comments/like', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ commentId })
      });

      if (!response.ok) {
        comment.isLiked = !comment.isLiked;
        comment._count.likes += comment.isLiked ? 1 : -1;
        comments = [...comments];

        const errorData = await response.json().catch(() => null);
        throw new Error(errorData?.message || 'Failed to like comment');
      }
    } catch (e) {
      console.error('Error liking comment:', e);
    }
  }

  async function handleFlag(commentId: number) {
    if (!$authStore.isAuthenticated) return;

    const reason = prompt('Please provide a reason for reporting this comment:');
    if (reason === null) return;

    let comment = comments.find(c => c.id === commentId);
    if (!comment) {
      for (const topComment of comments) {
        comment = topComment.replies.find(r => r.id === commentId);
        if (comment) break;
      }
    }
    if (!comment) return;

    comment.flagged = true;
    comments = [...comments];

    try {
      const response = await csrfFetch(`/api/comments/${commentId}/flag`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify({ reason })
      });

      if (!response.ok) {
        comment.flagged = false;
        comments = [...comments];

        const errorData = await response.json().catch(() => null);
        throw new Error(errorData?.message || 'Failed to flag comment');
      }
    } catch (e) {
      console.error('Error flagging comment:', e);
    }
  }

  function handleReplyClick(commentId: number) {
    if (!$authStore.isAuthenticated) return;
    replyingToId = commentId;
  }

  function handleReplyCancel() {
    replyingToId = null;
  }

  function handleReplyAdded(newReply: Comment) {
    const parentComment = comments.find(c => c.id === newReply.parentId);
    if (parentComment) {
      parentComment.replies = [...parentComment.replies, newReply];
      comments = [...comments];
    }
    replyingToId = null;
  }

  function handleCommentAdded(newComment: Comment) {
    comments = [newComment, ...comments];
  }

  $: if (mounted && mediaType === 'tv' && browser) {
    const episodeKey = `${season}-${episode}`;
    loadComments();
  }

  onMount(() => {
    mounted = true;
    loadComments();
  });
</script>

<div class="space-y-6">
  <CommentForm
    {mediaId}
    {mediaType}
    {season}
    {episode}
    onCommentAdded={handleCommentAdded}
  />

  <div class="flex items-center gap-4 text-sm text-gray-400">
    <span>Sort by:</span>
    <button
      class="hover:text-white transition-colors"
      class:text-white={sortBy === 'recent'}
      on:click={() => sortBy = 'recent'}
    >
      Most Recent
    </button>
    <span>•</span>
    <button
      class="hover:text-white transition-colors"
      class:text-white={sortBy === 'likes'}
      on:click={() => sortBy = 'likes'}
    >
      Most Liked
    </button>
  </div>

  {#if isLoading}
    <div class="flex justify-center py-8">
      <div class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-blue-500"></div>
    </div>
  {:else if error}
    <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-6 text-center">
      <p class="text-red-400">{error}</p>
      <button
        class="mt-4 px-4 py-2 bg-red-500/20 hover:bg-red-500/30 text-red-400 rounded-lg transition-colors"
        on:click={loadComments}
      >
        Try Again
      </button>
    </div>
  {:else if comments.length === 0}
    <div class="bg-gray-800/50 rounded-lg p-8 text-center backdrop-blur-sm border border-gray-700/50">
      <div class="flex flex-col items-center gap-4">
        <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
        </svg>
        <div>
          <p class="text-gray-400 text-lg mb-2">No comments yet</p>
          <p class="text-gray-500">Be the first to share your thoughts!</p>
        </div>
      </div>
    </div>
  {:else}
    <div class="space-y-4">
      {#each sortedComments as comment (comment.id)}
        {#if !comment.parentId}
          <div class="bg-gray-800/50 rounded-lg p-6 backdrop-blur-sm border border-gray-700/50">
            <div class="flex justify-between items-start mb-3">
              <div class="flex items-center gap-3">
                <div class="w-10 h-10 bg-gray-700 rounded-full flex items-center justify-center">
                  <span class="text-lg font-semibold">{comment.user.username[0].toUpperCase()}</span>
                </div>
                <div>
                  <div class="font-semibold">{comment.user.username}</div>
                  <div class="text-sm text-gray-400">
                    {formatDate(comment.createdAt)}
                  </div>
                </div>
              </div>
            </div>

            <div class="prose prose-invert max-w-none">
              {@html sanitizeContent(comment.content)}
            </div>

            <div class="flex items-center gap-4 mt-4 pt-4 border-t border-gray-700/50">
              <button
                class="flex items-center gap-2 text-sm text-gray-400 hover:text-blue-400 transition-colors"
                class:text-blue-400={comment.isLiked}
                on:click={() => handleLike(comment.id)}
                disabled={!$authStore.isAuthenticated}
              >
                <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
                </svg>
                <span>{comment._count.likes}</span>
              </button>

              {#if $authStore.isAuthenticated}
                <button
                  class="flex items-center gap-2 text-sm text-gray-400 hover:text-blue-400 transition-colors"
                  on:click={() => handleReplyClick(comment.id)}
                >
                  <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6" />
                  </svg>
                  <span>Reply</span>
                </button>
              {/if}

              {#if $authStore.isAuthenticated && !comment.flagged}
                <button
                  class="flex items-center gap-2 text-sm text-gray-400 hover:text-red-400 transition-colors"
                  on:click={() => handleFlag(comment.id)}
                >
                  <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                    <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
                  </svg>
                  <span>Report</span>
                </button>
              {/if}
            </div>

            {#if replyingToId === comment.id}
              <div class="mt-4 pl-8">
                <ReplyForm
                  {mediaId}
                  {mediaType}
                  {season}
                  {episode}
                  parentId={comment.id}
                  onReplyAdded={handleReplyAdded}
                  onCancel={handleReplyCancel}
                />
              </div>
            {/if}

            {#if comment.replies.length > 0}
              <div class="mt-4 pl-8 space-y-4">
                {#each comment.replies as reply (reply.id)}
                  <div class="bg-gray-800/30 rounded-lg p-4">
                    <div class="flex items-center gap-3 mb-2">
                      <div class="w-8 h-8 bg-gray-700 rounded-full flex items-center justify-center">
                        <span class="text-sm font-semibold">{reply.user.username[0].toUpperCase()}</span>
                      </div>
                      <div>
                        <div class="font-semibold">{reply.user.username}</div>
                        <div class="text-xs text-gray-400">
                          {formatDate(reply.createdAt)}
                        </div>
                      </div>
                    </div>
                    <div class="prose prose-invert max-w-none">
                      {@html sanitizeContent(reply.content)}
                    </div>
                    <div class="flex items-center gap-4 mt-4 pt-4 border-t border-gray-700/30">
                      <button
                        class="flex items-center gap-2 text-xs text-gray-400 hover:text-blue-400 transition-colors"
                        class:text-blue-400={reply.isLiked}
                        on:click={() => handleLike(reply.id)}
                        disabled={!$authStore.isAuthenticated}
                      >
                        <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5" />
                        </svg>
                        <span>{reply._count.likes}</span>
                      </button>

                      {#if $authStore.isAuthenticated && !reply.flagged}
                        <button
                          class="flex items-center gap-2 text-xs text-gray-400 hover:text-red-400 transition-colors"
                          on:click={() => handleFlag(reply.id)}
                        >
                          <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
                          </svg>
                          <span>Report</span>
                        </button>
                      {/if}
                    </div>
                  </div>
                {/each}
              </div>
            {/if}
          </div>
        {/if}
      {/each}
    </div>
  {/if}
</div>


================================================
FILE: src/lib/components/CommentModeration.svelte
================================================
<script lang="ts">
  import { page } from '$app/stores';
  import { commentsStore } from '$lib/stores/comments';
  import type { Comment } from '@prisma/client';
  import { csrfFetch } from '$lib/utils/csrf';

  export let comment: Comment;

  const isAdmin = $page.data.user?.isAdmin ?? false;

  async function deleteComment() {
    try {
      const response = await csrfFetch(`/api/comments/${comment.id}`, {
        method: 'DELETE',
        headers: {
          'Content-Type': 'application/json'
        }
      });

      if (!response.ok) {
        throw new Error('Failed to delete comment');
      }

      commentsStore.deleteComment(comment.id);
    } catch (error) {
      console.error('Error deleting comment:', error);
    }
  }

  async function flagComment() {
    try {
      const response = await csrfFetch(`/api/comments/${comment.id}/flag`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        }
      });

      if (!response.ok) {
        throw new Error('Failed to flag comment');
      }

      commentsStore.updateComment(comment.id, comment.content);
    } catch (error) {
      console.error('Error flagging comment:', error);
    }
  }

  async function unflagComment() {
    try {
      const response = await csrfFetch(`/api/comments/${comment.id}/unflag`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        }
      });

      if (!response.ok) {
        throw new Error('Failed to unflag comment');
      }

      commentsStore.updateComment(comment.id, comment.content);
    } catch (error) {
      console.error('Error unflagging comment:', error);
    }
  }
</script>

{#if isAdmin}
  <div class="flex items-center gap-2">
    <button
      class="text-sm text-red-500 hover:text-red-600"
      on:click={deleteComment}
      title="Delete comment"
    >
      <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
      </svg>
    </button>

    {#if comment.flagged}
      <button
        class="text-sm text-green-500 hover:text-green-600"
        on:click={unflagComment}
        title="Unflag comment"
      >
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
        </svg>
      </button>
    {:else}
      <button
        class="text-sm text-yellow-500 hover:text-yellow-600"
        on:click={flagComment}
        title="Flag comment"
      >
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 21v-4m0 0V5a2 2 0 012-2h6.5l1 1H21l-3 6 3 6h-8.5l-1-1H5a2 2 0 00-2 2zm9-13.5V9" />
        </svg>
      </button>
    {/if}
  </div>
{/if}


================================================
FILE: src/lib/components/EmojiPicker.svelte
================================================
<script lang="ts">
  import { createEventDispatcher, onDestroy } from 'svelte';
  import data from '@emoji-mart/data';
  import * as emojiMart from 'emoji-mart';

  export let buttonClass = '';
  export let disabled = false;

  const dispatch = createEventDispatcher<{ select: string }>();
  let showPicker = false;
  let pickerElement: HTMLDivElement;
  let buttonElement: HTMLButtonElement;
  type EmojiData = { native: string };
  type EmojiMartPicker = { destroy?: () => void };
  let picker: EmojiMartPicker | null = null;

  emojiMart.init({ data });

  function handleClickOutside(event: MouseEvent) {
    if (showPicker &&
        pickerElement &&
        !pickerElement.contains(event.target as Node) &&
        buttonElement &&
        !buttonElement.contains(event.target as Node)) {
      showPicker = false;
    }
  }

  function togglePicker() {
    if (!disabled) {
      showPicker = !showPicker;
    }
  }

  function handleSelect(emoji: EmojiData) {
    dispatch('select', emoji.native);
    showPicker = false;
  }

  function createPicker() {
    if (showPicker && pickerElement) {
      if (picker) {
        picker.destroy?.();
      }
      return new emojiMart.Picker({
        parent: pickerElement,
        data,
        onEmojiSelect: handleSelect,
        theme: 'dark',
        showPreview: false,
        showSkinTones: false,
        emojiSize: 20,
        emojiButtonSize: 28,
        maxFrequentRows: 0,
      });
    }
    return null;
  }

  $: picker = createPicker();

  onDestroy(() => {
    if (picker) {
      picker.destroy?.();
    }
  });
</script>

<svelte:window on:click={handleClickOutside} />

<div class="relative">
  <button
    bind:this={buttonElement}
    type="button"
    class="p-1 rounded hover:bg-gray-700/50 transition-colors {buttonClass}"
    class:opacity-50={disabled}
    class:cursor-not-allowed={disabled}
    on:click={togglePicker}
    {disabled}
    aria-label="Add emoji"
    title="Add emoji"
  >
    <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
    </svg>
  </button>

  {#if showPicker}
    <div
      bind:this={pickerElement}
      class="absolute bottom-full right-0 mb-2 z-50"
    ></div>
  {/if}
</div>

<style>
  :global(em-emoji-picker) {
    --rgb-background: 31 41 55;
    --rgb-input: 55 65 81;
    --rgb-color: 209 213 219;
    height: 350px !important;
  }
</style>


================================================
FILE: src/lib/components/EpisodeSelector.svelte
================================================
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  interface Season {
    season_number: number;
    name: string;
    episode_count: number;
  }

  interface Episode {
    episode_number: number;
    name: string;
    overview: string;
  }

  export let mediaId: number;
  export let showModal = false;

  const dispatch = createEventDispatcher();
  let seasons: Season[] = [];
  let episodes: Episode[] = [];
  let selectedSeason: number | undefined;
  let selectedEpisode: number | undefined;

  $: if (showModal && mediaId && !seasons.length) {
    loadSeasons();
  }

  async function loadSeasons() {
    try {
      const response = await fetch(`/api/tv/${mediaId}/seasons`);
      if (response.ok) {
        const data = await response.json();
        seasons = data.seasons.filter((s: Season) => s.season_number > 0);
        if (seasons.length > 0) {
          await selectSeason(seasons[0].season_number);
        }
      }
    } catch (error) {
      console.error('Error fetching seasons:', error);
    }
  }

  async function selectSeason(seasonNumber: number) {
    selectedSeason = seasonNumber;
    try {
      const response = await fetch(`/api/tv/${mediaId}/season/${seasonNumber}`);
      if (response.ok) {
        const data = await response.json();
        episodes = data.episodes;
      }
    } catch (error) {
      console.error('Error fetching episodes:', error);
    }
  }

  function selectEpisode(episodeNumber: number) {
    selectedEpisode = episodeNumber;
    dispatch('select', { season: selectedSeason, episode: episodeNumber });
    closeModal();
  }

  function closeModal() {
    showModal = false;
    dispatch('close');
  }
</script>

{#if showModal}
  <div class="fixed inset-0 bg-black/90 flex items-center justify-center z-50 p-4">
    <div class="bg-gray-800 rounded-lg w-full max-w-3xl max-h-[80vh] flex flex-col">
      <!-- Header -->
      <div class="flex justify-between items-center p-4 border-b border-gray-700">
        <h2 class="text-xl font-semibold">Select Episode</h2>
        <button
          type="button"
          class="text-gray-400 hover:text-white"
          on:click={closeModal}
          aria-label="Close episode selection"
        >
          <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
          </svg>
        </button>
      </div>

      <!-- Content -->
      <div class="flex-1 overflow-hidden flex divide-x divide-gray-700">
        <!-- Seasons -->
        <div class="w-1/3 overflow-y-auto">
          <div class="p-2">
            {#each seasons as season}
              <button
                type="button"
                class="w-full px-3 py-2 rounded text-left text-sm transition-colors mb-1"
                class:bg-primary-500={selectedSeason === season.season_number}
                class:bg-gray-700={selectedSeason !== season.season_number}
                class:hover:bg-primary-600={selectedSeason === season.season_number}
                class:hover:bg-gray-600={selectedSeason !== season.season_number}
                on:click={() => selectSeason(season.season_number)}
              >
                Season {season.season_number}
              </button>
            {/each}
          </div>
        </div>

        <!-- Episodes -->
        <div class="w-2/3 overflow-y-auto">
          <div class="p-2">
            {#if selectedSeason && episodes.length > 0}
              {#each episodes as episode}
                <button
                  type="button"
                  class="w-full px-3 py-2 rounded text-left text-sm transition-colors mb-1"
                  class:bg-primary-500={selectedEpisode === episode.episode_number}
                  class:bg-gray-700={selectedEpisode !== episode.episode_number}
                  class:hover:bg-primary-600={selectedEpisode === episode.episode_number}
                  class:hover:bg-gray-600={selectedEpisode !== episode.episode_number}
                  on:click={() => selectEpisode(episode.episode_number)}
                >
                  <div class="font-medium">
                    Episode {episode.episode_number}
                  </div>
                  <div class="text-xs text-gray-400 mt-0.5 line-clamp-1">
                    {episode.name}
                  </div>
                </button>
              {/each}
            {:else}
              <div class="text-gray-400 text-sm p-3">
                Select a season to view episodes
              </div>
            {/if}
          </div>
        </div>
      </div>
    </div>
  </div>
{/if}


================================================
FILE: src/lib/components/Hero.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';
  import type { TMDBMediaResponse } from '$lib/types/tmdb';

  export let media: TMDBMediaResponse;
  export let type: 'movie' | 'tv';

  let backdropUrl = '';

  $: if (media?.backdrop_path) {
    backdropUrl = `/api/image/original${media.backdrop_path}`;
  }

  $: title = type === 'movie' ? media?.title : media?.name;
  $: href = `/media/${media?.id}?type=${type}`;
</script>

<div class="relative w-full h-[60vh] min-h-[400px] overflow-hidden">
  {#if backdropUrl}
    <div class="absolute inset-0">
      <img
        src={backdropUrl}
        alt={title}
        class="w-full h-full object-cover"
      />
      <div class="absolute inset-0 bg-gradient-to-t from-gray-900 via-gray-900/80"></div>
    </div>
  {/if}

  <div class="absolute inset-0 flex items-end">
    <div class="container mx-auto px-4 pb-16">
      <div class="max-w-2xl">
        <h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
          {title}
        </h1>
        {#if media?.overview}
          <p class="text-lg text-gray-300 mb-6 line-clamp-3">
            {media.overview}
          </p>
        {/if}
        <div class="flex items-center gap-4">
          <a
            {href}
            class="inline-flex items-center gap-2 px-6 py-3 rounded-lg bg-primary-500 hover:bg-primary-600 text-white font-semibold transition-colors"
          >
            <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
            </svg>
            Watch Now
          </a>
          <div class="flex items-center gap-2 px-4 py-2 rounded-lg bg-gray-800/80 text-white">
            <svg class="w-5 h-5 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
              <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
            </svg>
            <span class="font-bold">{media?.vote_average?.toFixed(1) || '0.0'}</span>
            <span class="text-gray-400">/ 10</span>
          </div>
        </div>
      </div>
    </div>
  </div>
</div>


================================================
FILE: src/lib/components/Image.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';

  export let src: string | null;
  export let alt: string;
  export let class_ = '';
  export let sizes = '100vw';

  let loaded = false;
  let error = false;
  let imageElement: HTMLImageElement;

  const placeholder = '/placeholder.jpg';

  function generateSrcSet(path: string): string {
    const widths = [300, 500, 700, 900, 1100];
    return widths
      .map(width => {
        const size = width <= 500 ? 'w500' : width <= 700 ? 'w780' : 'original';
        return `/api/image/${size}${path} ${width}w`;
      })
      .join(', ');
  }

  $: finalSrc = src ? `/api/image/w500${src}` : placeholder;
  $: srcset = src ? generateSrcSet(src) : '';

  function handleLoad() {
    loaded = true;
  }

  function handleError() {
    error = true;
    if (imageElement) {
      imageElement.src = placeholder;
    }
  }

  $: if (src) {
    loaded = false;
    error = false;
  }
</script>

<div class="relative overflow-hidden {class_}">
  <img
    bind:this={imageElement}
    src={finalSrc}
    {srcset}
    {sizes}
    {alt}
    class="w-full h-full object-cover transition-opacity duration-300"
    class:opacity-0={!loaded}
    class:opacity-100={loaded}
    loading="lazy"
    on:load={handleLoad}
    on:error={handleError}
  />

  {#if !loaded}
    <div class="absolute inset-0 bg-gray-800 animate-pulse"></div>
  {/if}
</div>


================================================
FILE: src/lib/components/MediaCard.svelte
================================================
<script lang="ts">
  import Image from './Image.svelte';
  import WatchlistButton from './WatchlistButton.svelte';
  import { getReleaseType } from '$lib/services/release-type';
  import { onMount } from 'svelte';

  export let id: number;
  export let type: 'movie' | 'tv';
  export let title: string;
  export let posterPath: string | null;
  export let voteAverage: number;
  export let showWatchlist = true;

  let releaseType = 'Unknown Quality';
  let certification = '';
  let loading = true;

  $: href = `/media/${id}?type=${type}`;

  onMount(async () => {
    try {
      const releaseInfo = await getReleaseType(id, type);
      releaseType = releaseInfo.releaseType;
      certification = releaseInfo.certifications['US'] || '';
    } catch (error) {
      console.error('Error fetching release info:', error);
    } finally {
      loading = false;
    }
  });

  function handleWatchlistClick(event: MouseEvent) {
    event.stopPropagation();
    event.preventDefault();
  }

  function handleWatchlistKeydown(event: KeyboardEvent) {
    if (event.key === 'Enter') {
      event.stopPropagation();
      event.preventDefault();
    }
  }
</script>

<div class="group relative bg-gray-800 rounded-lg overflow-hidden transition-transform hover:scale-105">
  <a {href} class="block">
    <div class="aspect-[2/3] relative">
      <Image
        src={posterPath}
        alt={title}
        class_="w-full h-full"
        sizes="(min-width: 1280px) 16.666vw, (min-width: 1024px) 20vw, (min-width: 768px) 25vw, (min-width: 640px) 33.333vw, 50vw"
      />

      <!-- Rating Badge -->
      <div class="absolute top-2 left-2 flex items-center gap-1 px-2 py-1 rounded bg-black/80">
        <svg class="w-4 h-4 text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
          <path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
        </svg>
        <span class="text-sm font-bold text-white">{voteAverage.toFixed(1)}</span>
      </div>

      {#if !loading && releaseType !== 'Unknown Quality'}
        <div class="absolute top-2 right-10 px-2 py-1 text-xs font-semibold rounded bg-primary-500 text-white">
          {releaseType}
        </div>
      {/if}

      {#if certification}
        <div class="absolute bottom-2 right-2 px-2 py-1 text-xs font-semibold rounded bg-gray-700 text-white">
          {certification}
        </div>
      {/if}

      <!-- Hover Overlay -->
      <div class="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
        <div class="text-white text-center p-4">
          <span class="text-lg font-bold line-clamp-2">{title}</span>
          <div class="mt-2">
            <span class="inline-block px-3 py-1 rounded-full bg-primary-500 text-sm">
              {type === 'movie' ? 'Movie' : 'TV Show'}
            </span>
          </div>
        </div>
      </div>
    </div>
  </a>

  {#if showWatchlist}
    <div
      role="button"
      tabindex="0"
      class="absolute top-2 right-2 z-10"
      on:click={handleWatchlistClick}
      on:keydown={handleWatchlistKeydown}
    >
      <WatchlistButton {id} {type} {title} posterPath={posterPath} {voteAverage} />
    </div>
  {/if}
</div>

<style>
  .line-clamp-2 {
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }
</style>


================================================
FILE: src/lib/components/MediaFilters.svelte
================================================
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let type: 'movie' | 'tv';
  export let selectedSort = 'trending';
  export let selectedGenre = '';
  export let selectedYear = '';

  const dispatch = createEventDispatcher<{
    filter: { sort: string; genre: string; year: string };
  }>();

  const sortOptions = [
    { value: 'trending', label: 'Trending' },
    { value: 'popular', label: 'Popular' },
    { value: 'top_rated', label: 'Top Rated' },
    { value: 'now_playing', label: type === 'movie' ? 'Now Playing' : 'Currently Airing' },
    { value: 'upcoming', label: type === 'movie' ? 'Upcoming' : 'Upcoming Shows' }
  ];

  const genres = [
    { id: '28', name: 'Action' },
    { id: '12', name: 'Adventure' },
    { id: '16', name: 'Animation' },
    { id: '35', name: 'Comedy' },
    { id: '80', name: 'Crime' },
    { id: '99', name: 'Documentary' },
    { id: '18', name: 'Drama' },
    { id: '10751', name: 'Family' },
    { id: '14', name: 'Fantasy' },
    { id: '36', name: 'History' },
    { id: '27', name: 'Horror' },
    { id: '10402', name: 'Music' },
    { id: '9648', name: 'Mystery' },
    { id: '10749', name: 'Romance' },
    { id: '878', name: 'Science Fiction' },
    { id: '10770', name: 'TV Movie' },
    { id: '53', name: 'Thriller' },
    { id: '10752', name: 'War' },
    { id: '37', name: 'Western' }
  ];

  const currentYear = new Date().getFullYear();
  const years = Array.from({ length: 50 }, (_, i) => (currentYear - i).toString());

  function handleChange() {
    dispatch('filter', {
      sort: selectedSort,
      genre: selectedGenre,
      year: selectedYear
    });
  }
</script>

<div class="bg-gray-800 rounded-lg p-4 mb-6">
  <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
    <div>
      <label for="sort" class="block text-sm font-medium text-gray-300 mb-2">
        Sort By
      </label>
      <select
        id="sort"
        bind:value={selectedSort}
        on:change={handleChange}
        class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
      >
        {#each sortOptions as option}
          <option value={option.value}>{option.label}</option>
        {/each}
      </select>
    </div>

    <div>
      <label for="genre" class="block text-sm font-medium text-gray-300 mb-2">
        Genre
      </label>
      <select
        id="genre"
        bind:value={selectedGenre}
        on:change={handleChange}
        class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
      >
        <option value="">All Genres</option>
        {#each genres as genre}
          <option value={genre.id}>{genre.name}</option>
        {/each}
      </select>
    </div>

    <div>
      <label for="year" class="block text-sm font-medium text-gray-300 mb-2">
        Year
      </label>
      <select
        id="year"
        bind:value={selectedYear}
        on:change={handleChange}
        class="w-full px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
      >
        <option value="">All Years</option>
        {#each years as year}
          <option value={year}>{year}</option>
        {/each}
      </select>
    </div>
  </div>
</div>


================================================
FILE: src/lib/components/MediaPlayer.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';

  export let src: string;
  export let title: string;
  export let autoplay = false;
  export let controls = true;
  export let muted = false;

  let player: HTMLVideoElement;
  let volume = 1;

  onMount(() => {
    if (player) {
      player.volume = volume;
    }
  });

  function handleKeyDown(event: KeyboardEvent) {
    switch (event.code) {
      case 'Space':
        event.preventDefault();
        if (player) {
          if (player.paused) {
            player.play();
          } else {
            player.pause();
          }
        }
        break;
      case 'ArrowLeft':
        event.preventDefault();
        if (player) player.currentTime -= 5;
        break;
      case 'ArrowRight':
        event.preventDefault();
        if (player) player.currentTime += 5;
        break;
      case 'ArrowUp':
        event.preventDefault();
        if (player) player.volume = Math.min(1, player.volume + 0.1);
        break;
      case 'ArrowDown':
        event.preventDefault();
        if (player) player.volume = Math.max(0, player.volume - 0.1);
        break;
    }
  }
</script>

<div
  class="relative w-full h-full bg-black focus:outline-none focus:ring-2 focus:ring-primary-500"
  on:keydown={handleKeyDown}
  role="application"
  aria-label="Video player for {title}"
>
  <video
    bind:this={player}
    {src}
    class="w-full h-full"
    {autoplay}
    {controls}
    {muted}
  >
    <track kind="captions" />
  </video>
</div>


================================================
FILE: src/lib/components/MentionList.svelte
================================================
<script lang="ts">
  import { createEventDispatcher } from 'svelte';

  export let items: { id: number; username: string }[] = [];
  export let command: (props: { id: number; label: string }) => void;

  let selectedIndex = 0;
  const dispatch = createEventDispatcher();

  function onKeyDown(event: KeyboardEvent) {
    if (event.key === 'ArrowUp') {
      event.preventDefault();
      upHandler();
      return true;
    }

    if (event.key === 'ArrowDown') {
      event.preventDefault();
      downHandler();
      return true;
    }

    if (event.key === 'Enter') {
      event.preventDefault();
      enterHandler();
      return true;
    }

    return false;
  }

  function upHandler() {
    selectedIndex = (selectedIndex + items.length - 1) % items.length;
  }

  function downHandler() {
    selectedIndex = (selectedIndex + 1) % items.length;
  }

  function enterHandler() {
    selectItem(selectedIndex);
  }

  function selectItem(index: number) {
    const item = items[index];
    if (item) {
      command({ id: item.id, label: item.username });
    }
  }

  dispatch('keydown', { onKeyDown });
</script>

<div class="mention-list bg-gray-800 rounded-lg shadow-lg overflow-hidden">
  {#each items as item, index}
    <button
      class="w-full px-4 py-2 text-left hover:bg-gray-700 transition-colors"
      class:bg-gray-700={index === selectedIndex}
      on:click={() => selectItem(index)}
    >
      <div class="flex items-center gap-2">
        <div class="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center">
          <span class="text-sm font-medium">
            {item.username[0].toUpperCase()}
          </span>
        </div>
        <span class="text-sm">{item.username}</span>
      </div>
    </button>
  {/each}
</div>

<style>
  .mention-list {
    max-height: 200px;
    overflow-y: auto;
  }
</style>


================================================
FILE: src/lib/components/Navbar.svelte
================================================
<script lang="ts">
  import { page } from '$app/stores';
  import { onMount } from 'svelte';
  import { authStore } from '$lib/stores/auth';
  import { csrfFetch } from '$lib/utils/csrf';

  let isScrolled = false;
  let isMobileMenuOpen = false;
  let isUserMenuOpen = false;

  onMount(() => {
    const handleScroll = () => {
      isScrolled = window.scrollY > 0;
    };

    window.addEventListener('scroll', handleScroll);
    return () => window.removeEventListener('scroll', handleScroll);
  });

  const navItems = [
    { href: '/', label: 'Home' },
    { href: '/movies', label: 'Movies' },
    { href: '/tv', label: 'TV Shows' },
    { href: '/watchlist', label: 'Watchlist', requiresAuth: true }
  ];

  async function handleLogout() {
    try {
      await csrfFetch('/api/auth/logout', { method: 'POST' });
      window.location.href = '/';
    } catch (error) {
      console.error('Logout failed:', error);
    }
  }
</script>

<nav
  class="fixed top-0 left-0 right-0 z-50 transition-colors duration-300"
  class:bg-gray-900={isScrolled}
  class:backdrop-blur={isScrolled}
>
  <div class="container mx-auto px-4">
    <div class="flex items-center justify-between h-16">
      <!-- Logo -->
      <a href="/" class="flex items-center gap-2">
        <span class="text-2xl font-bold text-primary-400">Streamium</span>
      </a>

      <!-- Desktop Navigation -->
      <div class="hidden md:flex items-center gap-6">
        {#each navItems as item}
          {#if !item.requiresAuth || $authStore.isAuthenticated}
            <a
              href={item.href}
              class="text-gray-300 hover:text-white transition-colors"
              class:text-primary-400={$page.url.pathname === item.href}
            >
              {item.label}
            </a>
          {/if}
        {/each}
      </div>

      <!-- Search and User Menu -->
      <div class="flex items-center gap-4">
        <a
          href="/search"
          class="p-2 text-gray-300 hover:text-white transition-colors"
          aria-label="Search"
        >
          <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
          </svg>
        </a>

        {#if $authStore.isAuthenticated}
          <!-- User Menu -->
          <div class="relative">
            <button
              type="button"
              class="flex items-center gap-2 p-2 text-gray-300 hover:text-white transition-colors"
              on:click={() => isUserMenuOpen = !isUserMenuOpen}
              aria-label="User menu"
            >
              <span class="text-sm">{$authStore.user?.username}</span>
              <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
              </svg>
            </button>

            {#if isUserMenuOpen}
              <div class="absolute right-0 mt-2 w-48 py-2 bg-gray-800 rounded-lg shadow-xl">
                {#if $authStore.user?.isAdmin}
                  <a
                    href="/admin/moderation"
                    class="block px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-700"
                    on:click={() => isUserMenuOpen = false}
                  >
                    <div class="flex items-center gap-2">
                      <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
                        <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
                      </svg>
                      <span>Moderation</span>
                    </div>
                  </a>
                {/if}
                <button
                  type="button"
                  class="w-full text-left px-4 py-2 text-gray-300 hover:text-white hover:bg-gray-700"
                  on:click={handleLogout}
                >
                  Logout
                </button>
              </div>
            {/if}
          </div>
        {:else}
          <!-- Auth Links -->
          <div class="hidden md:flex items-center gap-4">
            <a
              href="/login"
              class="text-gray-300 hover:text-white transition-colors"
            >
              Login
            </a>
            <a
              href="/register"
              class="px-4 py-2 bg-primary-500 text-white rounded-lg hover:bg-primary-600 transition-colors"
            >
              Register
            </a>
          </div>
        {/if}

        <!-- Mobile Menu Button -->
        <button
          type="button"
          class="md:hidden p-2 text-gray-300 hover:text-white transition-colors"
          on:click={() => isMobileMenuOpen = !isMobileMenuOpen}
          aria-label="Toggle mobile menu"
        >
          <svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
            {#if isMobileMenuOpen}
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
            {:else}
              <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
            {/if}
          </svg>
        </button>
      </div>
    </div>

    <!-- Mobile Navigation -->
    {#if isMobileMenuOpen}
      <div class="md:hidden py-4 space-y-2">
        {#each navItems as item}
          {#if !item.requiresAuth || $authStore.isAuthenticated}
            <a
              href={item.href}
              class="block px-4 py-2 text-gray-300 hover:text-white transition-colors"
              class:text-primary-400={$page.url.pathname === item.href}
              on:click={() => isMobileMenuOpen = false}
            >
              {item.label}
            </a>
          {/if}
        {/each}

        {#if $authStore.isAuthenticated && $authStore.user?.isAdmin}
          <a
            href="/admin/moderation"
            class="block px-4 py-2 text-gray-300 hover:text-white transition-colors"
            on:click={() => isMobileMenuOpen = false}
          >
            Moderation
          </a>
        {/if}

        {#if !$authStore.isAuthenticated}
          <div class="pt-4 border-t border-gray-700">
            <a
              href="/login"
              class="block px-4 py-2 text-gray-300 hover:text-white transition-colors"
              on:click={() => isMobileMenuOpen = false}
            >
              Login
            </a>
            <a
              href="/register"
              class="block px-4 py-2 text-gray-300 hover:text-white transition-colors"
              on:click={() => isMobileMenuOpen = false}
            >
              Register
            </a>
          </div>
        {/if}
      </div>
    {/if}
  </div>
</nav>

<!-- Spacer to prevent content from being hidden under fixed navbar -->
<div class="h-16"></div>


================================================
FILE: src/lib/components/NextEpisode.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';

  export let mediaId: number;
  export let currentSeason: number;
  export let currentEpisode: number;
  export let onSelect: (season: number, episode: number) => void;

  interface Episode {
    name: string;
    episode_number: number;
    season_number?: number;
  }

  interface Season {
    season_number: number;
  }

  let nextEpisode: Episode | null = null;
  let loading = true;

  async function loadNextEpisode() {
    loading = true;
    try {

      const response = await fetch(`/api/tv/${mediaId}/season/${currentSeason}`);
      if (response.ok) {
        const data = await response.json();
        const episodes = data.episodes || [];
        const nextInSeason = episodes.find((ep: Episode) => ep.episode_number === currentEpisode + 1);

        if (nextInSeason) {
          nextEpisode = { ...nextInSeason, season_number: currentSeason };
        } else {

          const seasonsResponse = await fetch(`/api/tv/${mediaId}/seasons`);
          if (seasonsResponse.ok) {
            const seasonsData = await seasonsResponse.json();
            const seasons = seasonsData.seasons.filter((s: Season) => s.season_number > 0);
            const nextSeason = seasons.find((s: Season) => s.season_number === currentSeason + 1);

            if (nextSeason) {
              const nextSeasonResponse = await fetch(`/api/tv/${mediaId}/season/${nextSeason.season_number}`);
              if (nextSeasonResponse.ok) {
                const nextSeasonData = await nextSeasonResponse.json();
                const firstEpisode = nextSeasonData.episodes[0];
                if (firstEpisode) {
                  nextEpisode = { ...firstEpisode, season_number: nextSeason.season_number };
                }
              }
            }
          }
        }
      }
    } catch (error) {
      console.error('Error loading next episode:', error);
    } finally {
      loading = false;
    }
  }

  function handleNextEpisode() {
    if (nextEpisode && nextEpisode.season_number) {
      onSelect(nextEpisode.season_number, nextEpisode.episode_number);
    }
  }

  onMount(() => {
    if (mediaId && currentSeason && currentEpisode) {
      loadNextEpisode();
    }
  });

  $: if (mediaId && currentSeason && currentEpisode) {
    if (typeof window !== 'undefined') {
      loadNextEpisode();
    }
  }
</script>

{#if !loading && nextEpisode}
  <button
    class="px-4 py-2 bg-gray-700 text-white rounded-lg font-medium hover:bg-gray-600 transition-colors whitespace-nowrap flex items-center gap-2"
    on:click={handleNextEpisode}
  >
    <span>Next: {nextEpisode.season_number !== currentSeason ? `S${nextEpisode.season_number}E${nextEpisode.episode_number}` : `E${nextEpisode.episode_number}`}</span>
    <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
    </svg>
  </button>
{/if}


================================================
FILE: src/lib/components/Pagination.svelte
================================================
<script lang="ts">
  import { filters } from '$lib/stores/filters';

  export let totalPages: number;
  export let currentPage: number;
  export let onPageChange: (page: number) => void;

  $: pages = generatePageNumbers(currentPage, totalPages);

  function generatePageNumbers(current: number, total: number) {
    const pages: (number | string)[] = [];
    const maxVisiblePages = 7;

    if (total <= maxVisiblePages) {
      return Array.from({ length: total }, (_, i) => i + 1);
    }


    pages.push(1);


    let start = Math.max(2, current - 2);
    let end = Math.min(total - 1, current + 2);


    if (current <= 4) {
      end = 5;
    }


    if (current >= total - 3) {
      start = total - 4;
    }


    if (start > 2) {
      pages.push('...');
    }


    for (let i = start; i <= end; i++) {
      pages.push(i);
    }


    if (end < total - 1) {
      pages.push('...');
    }


    pages.push(total);

    return pages;
  }

  function handlePageChange(page: number) {
    if (page === currentPage) return;
    onPageChange(page);

    window.scrollTo({ top: 0, behavior: 'smooth' });
  }
</script>

<nav class="flex justify-center mt-8" aria-label="Pagination">
  <ul class="flex items-center gap-1">
    <!-- Previous Button -->
    <li>
      <button
        class="px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
        disabled={currentPage === 1}
        on:click={() => handlePageChange(currentPage - 1)}
        aria-label="Previous page"
      >
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
        </svg>
      </button>
    </li>

    <!-- Page Numbers -->
    {#each pages as page}
      <li>
        {#if typeof page === 'string'}
          <span class="px-4 py-2 text-gray-400">
            {page}
          </span>
        {:else}
          <button
            class="min-w-[40px] px-4 py-2 rounded-lg {currentPage === page
              ? 'bg-purple-600 text-white'
              : 'text-gray-400 hover:text-white hover:bg-gray-700'}"
            on:click={() => handlePageChange(page)}
            aria-label="Page {page}"
            aria-current={currentPage === page ? 'page' : undefined}
          >
            {page}
          </button>
        {/if}
      </li>
    {/each}

    <!-- Next Button -->
    <li>
      <button
        class="px-3 py-2 rounded-lg text-gray-400 hover:text-white hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
        disabled={currentPage === totalPages}
        on:click={() => handlePageChange(currentPage + 1)}
        aria-label="Next page"
      >
        <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
        </svg>
      </button>
    </li>
  </ul>
</nav>

{#if totalPages > 1}
  <div class="text-center mt-2 text-sm text-gray-400">
    Page {currentPage} of {totalPages}
  </div>
{/if}


================================================
FILE: src/lib/components/ReplyForm.svelte
================================================
<script lang="ts">
  import RichTextEditor from './RichTextEditor.svelte';
  import { validateComment } from '$lib/shared/comment-validation';
  import { authStore } from '$lib/stores/auth';
  import { csrfFetch } from '$lib/utils/csrf';

  interface User {
    id: number;
    username: string;
  }

  export let mediaId: number;
  export let mediaType: 'movie' | 'tv';
  export let season: number | undefined = undefined;
  export let episode: number | undefined = undefined;
  export let parentId: number;
  export let onReplyAdded: (reply: {
    id: number;
    content: string;
    createdAt: string;
    user: User;
    replies: never[];
    _count: { likes: number };
    isLiked: boolean;
    flagged: boolean;
    parentId: number;
  }) => void;
  export let onCancel: () => void;

  let content = '<p></p>';
  let isSubmitting = false;
  let error = '';
  let charCount = 0;
  let isValid = false;
  let editor: RichTextEditor;
  const MAX_CHARS = 1000;

  $: {
    if (content === '<p></p>') {
      isValid = false;
      error = '';
      charCount = 0;
    } else {
      const validation = validateComment(content);
      isValid = validation.isValid && charCount <= MAX_CHARS;
      if (!validation.isValid && validation.error) {
        error = validation.error;
      } else if (charCount > MAX_CHARS) {
        error = 'Reply is too long';
      } else {
        error = '';
      }
    }
  }

  async function handleSubmit() {
    if (!isValid || isSubmitting) return;

    try {
      isSubmitting = true;
      error = '';

      const validation = validateComment(content);
      if (!validation.isValid) {
        error = validation.error || 'Invalid reply';
        return;
      }

      const response = await csrfFetch('/api/comments', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          mediaId,
          mediaType,
          content,
          parentId,
          season: mediaType === 'tv' ? season : undefined,
          episode: mediaType === 'tv' ? episode : undefined
        })
      });

      if (!response.ok) {
        const data = await response.json();
        throw new Error(data.error || 'Failed to post reply');
      }

      const newReply = await response.json();
      editor?.clear();
      onReplyAdded({
        ...newReply,
        user: $authStore.user!,
        replies: [],
        _count: { likes: 0 },
        isLiked: false,
        flagged: false
      });
    } catch (err) {
      if (err instanceof Error) {
        error = err.message;
      } else {
        error = 'An unexpected error occurred';
      }
    } finally {
      isSubmitting = false;
    }
  }

  function handleContentInput(event: CustomEvent<string>) {
    content = event.detail;

    const textContent = content.replace(/<[^>]*>/g, '');
    charCount = textContent.length;
  }
</script>

<form on:submit|preventDefault={handleSubmit} class="space-y-4">
  <div class="space-y-2">
    <RichTextEditor
      bind:this={editor}
      bind:content
      on:input={handleContentInput}
      class_="min-h-[100px] bg-gray-900/50"
    />

    <div class="flex justify-between items-center text-sm">
      <span class="text-gray-400">
        {charCount}/{MAX_CHARS} characters
      </span>
      {#if error}
        <span class="text-red-400">{error}</span>
      {/if}
    </div>
  </div>

  <div class="flex justify-end gap-2">
    <button
      type="button"
      class="px-4 py-2 bg-gray-700 text-white rounded-md hover:bg-gray-600 transition-colors"
      on:click={onCancel}
    >
      Cancel
    </button>
    <button
      type="submit"
      class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
      disabled={!isValid || isSubmitting}
    >
      {isSubmitting ? 'Posting...' : 'Post Reply'}
    </button>
  </div>
</form>


================================================
FILE: src/lib/components/RichTextEditor.svelte
================================================
<script lang="ts">
  import { onMount, onDestroy, createEventDispatcher } from 'svelte';
  import { Editor } from '@tiptap/core';
  import StarterKit from '@tiptap/starter-kit';
  import EmojiPicker from './EmojiPicker.svelte';

  const dispatch = createEventDispatcher<{ input: string }>();
  const EMPTY_CONTENT = '<p></p>';

  export let content = EMPTY_CONTENT;
  export let disabled = false;
  export let class_ = '';

  let element: HTMLDivElement;
  let editor: Editor;

  onMount(() => {
    editor = new Editor({
      element,
      extensions: [
        StarterKit.configure({
          heading: false,
          bulletList: false,
          orderedList: false,
          code: false,
          codeBlock: false,
          blockquote: false,
          horizontalRule: false,
          hardBreak: false,
          history: {},
        }),
      ],
      content: EMPTY_CONTENT,
      editable: !disabled,
      onUpdate: ({ editor }) => {
        content = editor.getHTML();
        dispatch('input', content);
      },
      editorProps: {
        attributes: {
          class: 'prose prose-invert max-w-none min-h-[120px] p-4 focus:outline-none',
        },
      },
    });

    return () => {
      editor.destroy();
    };
  });

  onDestroy(() => {
    if (editor) {
      editor.destroy();
    }
  });

  export function clear() {
    if (editor) {
      editor.commands.setContent(EMPTY_CONTENT);
      content = EMPTY_CONTENT;
      dispatch('input', content);
    }
  }

  function handleEmojiSelect(event: CustomEvent<string>) {
    if (editor) {
      editor.commands.insertContent(event.detail);
    }
  }

  $: if (editor && disabled !== undefined) {
    editor.setEditable(!disabled);
  }
</script>

<div class="relative {class_} bg-transparent rounded-lg border border-gray-700/50">
  <div
    bind:this={element}
    class="prose-sm prose-invert"
  ></div>

  <div class="border-t border-gray-700/50 p-2 flex gap-2">
    <button
      type="button"
      class="p-1 rounded hover:bg-gray-700/50 transition-colors"
      class:text-blue-400={editor?.isActive('bold')}
      on:click={() => editor?.chain().focus().toggleBold().run()}
      disabled={disabled}
      aria-label="Bold"
      title="Bold"
    >
      <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
        <path d="M15.6 11.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 7.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/>
      </svg>
    </button>

    <button
      type="button"
      class="p-1 rounded hover:bg-gray-700/50 transition-colors"
      class:text-blue-400={editor?.isActive('italic')}
      on:click={() => editor?.chain().focus().toggleItalic().run()}
      disabled={disabled}
      aria-label="Italic"
      title="Italic"
    >
      <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
        <path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/>
      </svg>
    </button>

    <button
      type="button"
      class="p-1 rounded hover:bg-gray-700/50 transition-colors"
      class:text-blue-400={editor?.isActive('strike')}
      on:click={() => editor?.chain().focus().toggleStrike().run()}
      disabled={disabled}
      aria-label="Strikethrough"
      title="Strikethrough"
    >
      <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
        <path d="M7.24 8.75c-.26-.48-.39-1.03-.39-1.67 0-.61.13-1.16.4-1.67.26-.5.63-.93 1.11-1.29.48-.35 1.05-.63 1.7-.83.66-.19 1.39-.29 2.18-.29.81 0 1.54.11 2.21.34.66.22 1.23.54 1.69.94.47.4.83.88 1.08 1.43.25.55.38 1.15.38 1.81h-3.01c0-.31-.05-.59-.15-.85-.09-.27-.24-.49-.44-.68-.2-.19-.45-.33-.75-.44-.3-.1-.66-.16-1.06-.16-.39 0-.74.04-1.03.13-.29.09-.53.21-.72.36-.19.16-.34.34-.44.55-.1.21-.15.43-.15.66 0 .48.25.88.74 1.21.38.25.77.48 1.41.7H7.39c-.05-.08-.11-.17-.15-.25zM21 12v-2H3v2h9.62c.18.07.4.14.55.2.37.17.66.34.87.51.21.17.35.36.43.57.07.2.11.43.11.69 0 .23-.05.45-.14.66-.09.2-.23.38-.42.53-.19.15-.42.26-.71.35-.29.08-.63.13-1.01.13-.43 0-.83-.04-1.18-.13s-.66-.23-.91-.42c-.25-.19-.45-.44-.59-.75-.14-.31-.25-.76-.25-1.21H6.4c0 .55.08 1.13.24 1.58.16.45.37.85.65 1.21.28.35.6.66.98.92.37.26.78.48 1.22.65.44.17.9.3 1.38.39.48.08.96.13 1.44.13.8 0 1.53-.09 2.18-.28s1.21-.45 1.67-.79c.46-.34.82-.77 1.07-1.27s.38-1.07.38-1.71c0-.6-.1-1.14-.31-1.61-.05-.11-.11-.23-.17-.33H21z"/>
      </svg>
    </button>

    <div class="flex-1"></div>

    <EmojiPicker
      on:select={handleEmojiSelect}
      disabled={disabled}
    />
  </div>
</div>

<style>
  :global(.ProseMirror) {
    outline: none;
  }

  :global(.ProseMirror p.is-editor-empty:first-child::before) {
    content: attr(data-placeholder);
    float: left;
    color: #9ca3af;
    pointer-events: none;
    height: 0;
  }
</style>


================================================
FILE: src/lib/components/Toast.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';
  import { fade, fly } from 'svelte/transition';
  import { toastStore, type Toast } from '$lib/stores/toast';

  let toasts: Toast[] = [];
  toastStore.subscribe(value => {
    toasts = value;
  });

  const icons = {
    success: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
    </svg>`,
    error: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
    </svg>`,
    warning: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
    </svg>`,
    info: `<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
    </svg>`
  };

  const colors = {
    success: 'bg-green-500',
    error: 'bg-red-500',
    warning: 'bg-yellow-500',
    info: 'bg-blue-500'
  };

  function removeToast(id: string) {
    toastStore.remove(id);
  }
</script>

<div class="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
  {#each toasts as toast (toast.id)}
    <div
      class="flex items-center gap-3 min-w-[300px] max-w-md p-4 text-white rounded-lg shadow-lg"
      class:bg-green-500={toast.type === 'success'}
      class:bg-red-500={toast.type === 'error'}
      class:bg-yellow-500={toast.type === 'warning'}
      class:bg-blue-500={toast.type === 'info'}
      transition:fly={{ y: 50, duration: 200 }}
      role="alert"
    >
      <div class="flex-shrink-0">
        {@html icons[toast.type]}
      </div>
      <p class="flex-1">{toast.message}</p>
      <button
        class="flex-shrink-0 hover:opacity-75"
        on:click={() => removeToast(toast.id)}
        aria-label="Close notification"
      >
        <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
        </svg>
      </button>
    </div>
  {/each}
</div>


================================================
FILE: src/lib/components/VideoPlayer.svelte
================================================
<script lang="ts">
  import { onMount, onDestroy } from 'svelte';
  import { browser } from '$app/environment';
  import { providers, getDefaultProvider, type Provider } from '$lib/services/providers';

  export let mediaId: string | number;
  export let mediaType: 'movie' | 'tv';
  export let title: string;
  export let season: number | undefined = undefined;
  export let episode: number | undefined = undefined;

  let selectedProvider = getDefaultProvider();
  let iframe: HTMLIFrameElement;
  let loading = true;
  let error: string | null = null;
  let retryCount = 0;
  const MAX_RETRIES = providers.length;

  $: embedUrl = selectedProvider.getEmbedUrl(mediaId, mediaType, season, episode);

  onMount(() => {
    if (browser) {
      window.addEventListener('message', handleProviderMessage);
    }
  });

  onDestroy(() => {
    if (browser) {
      window.removeEventListener('message', handleProviderMessage);
    }
  });

  function handleProviderMessage(event: MessageEvent) {
    if (event.data?.type === 'error') {
      handleError();
    }
  }

  export function changeProvider(providerId: string) {
    const newProvider = providers.find(p => p.id === providerId);
    if (newProvider) {
      selectedProvider = newProvider;
      localStorage.setItem('selectedProvider', newProvider.id);
      loading = true;
      error = null;
      retryCount = 0;
    }
  }

  function handleIframeLoad() {
    loading = false;
    retryCount = 0;
  }

  function handleError() {
    if (retryCount < MAX_RETRIES) {
      retryCount++;
      tryNextProvider();
    } else {
      loading = false;
      error = 'Failed to load video player after trying all providers. Please try again later.';
    }
  }

  function handleIframeError() {
    handleError();
  }

  function tryNextProvider() {
    const currentIndex = providers.indexOf(selectedProvider);
    const nextIndex = (currentIndex + 1) % providers.length;
    selectedProvider = providers[nextIndex];
    localStorage.setItem('selectedProvider', selectedProvider.id);
    loading = true;
    error = null;
  }
</script>

<div class="video-player-frame relative w-full bg-black rounded-lg overflow-hidden">
  {#if loading}
    <div class="absolute inset-0 flex items-center justify-center bg-gray-900 z-20">
      <div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
    </div>
  {/if}

  <iframe
    bind:this={iframe}
    {title}
    src={embedUrl}
    class="absolute top-0 left-0 w-full h-full"
    frameborder="0"
    scrolling="no"
    allowfullscreen={true}
    allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share; fullscreen"
    loading="lazy"
    on:load={handleIframeLoad}
    on:error={handleIframeError}
  ></iframe>

  {#if error}
    <div class="absolute inset-0 flex items-center justify-center bg-gray-900 z-20">
      <div class="text-red-500 text-center p-4">
        <p class="mb-2">{error}</p>
        <button
          type="button"
          class="px-4 py-2 bg-primary-500 text-white rounded hover:bg-primary-600 transition-colors"
          on:click={() => {
            retryCount = 0;
            tryNextProvider();
          }}
        >
          Try Different Provider
        </button>
      </div>
    </div>
  {/if}
</div>

<style>
  .video-player-frame {
    padding-top: 56.25%;
  }
</style>


================================================
FILE: src/lib/components/WatchlistButton.svelte
================================================
<script lang="ts">
  import { onMount } from 'svelte';
  import { browser } from '$app/environment';
  import { watchlistStore } from '$lib/stores/watchlist';
  import { toastStore } from '$lib/stores/toast';
  import { authStore } from '$lib/stores/auth';

  export let id: number;
  export let type: string;
  export let title: string;
  export let posterPath: string | null;
  export let voteAverage: number;

  let inWatchlist = false;
  let loading = false;
  let mounted = false;

  async function checkWatchlistStatus() {
    if (!browser || !mounted || !$authStore.user) return;

    try {
      inWatchlist = await watchlistStore.isInWatchlist(id, type);
    } catch (error) {
      console.error('Failed to check watchlist status:', error);
    }
  }

  async function toggleWatchlist() {
    if (loading || !$authStore.user) {
      if (!$authStore.user) {
        toastStore.error('Please login to add to watchlist');
      }
      return;
    }

    loading = true;

    try {
      if (inWatchlist) {
        await watchlistStore.removeFromWatchlist(id, type);
        toastStore.success('Removed from watchlist');
      } else {
        await watchlistStore.addToWatchlist(id, type, title, posterPath, voteAverage);
        toastStore.success('Added to watchlist');
      }
      inWatchlist = !inWatchlist;
    } catch (error) {
      console.error('Failed to update watchlist:', error);
      toastStore.error('Failed to update watchlist');
    } finally {
      loading = false;
    }
  }

  onMount(() => {
    mounted = true;
    checkWatchlistStatus();
  });

  $: if ($authStore.user) {
    checkWatchlistStatus();
  }
</script>

<button
  type="button"
  class="p-2 rounded-full bg-gray-900/80 hover:bg-gray-900 transition-colors"
  on:click={toggleWatchlist}
  disabled={loading}
  aria-label={inWatchlist ? `Remove ${title} from watchlist` : `Add ${title} to watchlist`}
>
  <svg
    class="w-5 h-5"
    class:text-primary-400={inWatchlist}
    class:text-gray-400={!inWatchlist}
    fill="currentColor"
    viewBox="0 0 20 20"
  >
    {#if inWatchlist}
      <path
        d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"
      />
    {:else}
      <path
        d="M5 4a2 2 0 012-2h6a2 2 0 012 2v14l-5-2.5L5 18V4z"
        fill="none"
        stroke="currentColor"
        stroke-width="2"
        stroke-linejoin="round"
      />
    {/if}
  </svg>
</button>


================================================
FILE: src/lib/constants/security.ts
================================================
export const CSRF_COOKIE_NAME = "csrf";
export const CSRF_HEADER_NAME = "x-csrf-token";


================================================
FILE: src/lib/extensions/mention.ts
================================================
import { Extension, type Editor, type Range } from "@tiptap/core";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";

type HTMLAttrs = Record<string, string | number | boolean | null | undefined>;

interface MentionNode {
  attrs: {
    id?: string | null;
    label?: string | null;
  };
}

export interface MentionOptions {
  HTMLAttributes?: HTMLAttrs;
  renderLabel?: (props: { options: MentionOptions; node: MentionNode }) => string;
  suggestion?: Partial<SuggestionOptions>;
}

interface CommandProps {
  id: number;
  label: string;
}

export const Mention = Extension.create<MentionOptions>({
  name: "mention",

  addOptions() {
    return {
      HTMLAttributes: {},
      renderLabel({ options, node }) {
        return `@${node.attrs.label ?? ""}`;
      },
      suggestion: {
        char: "@",
        command: ({
          editor,
          range,
          props,
        }: {
          editor: Editor;
          range: Range;
          props: CommandProps;
        }) => {
          editor
            .chain()
            .focus()
            .insertContentAt(range, [
              {
                type: "text",
                text: `@${props.label}`,
                marks: [
                  {
                    type: "mention",
                    attrs: { id: props.id, label: props.label },
                  },
                ],
              },
              {
                type: "text",
                text: " ",
              },
            ])
            .run();
        },
        allow: ({ editor, range }: { editor: Editor; range: Range }) => {
          return editor.can().insertContentAt(range, {});
        },
      },
    };
  },

  addAttributes() {
    return {
      id: {
        default: null,
        parseHTML: (element: HTMLElement) =>
          element.getAttribute("data-mention-id"),
        renderHTML: (attributes: { id?: string | null }) => {
          if (!attributes.id) {
            return {};
          }

          return {
            "data-mention-id": attributes.id,
          };
        },
      },
      label: {
        default: null,
        parseHTML: (element: HTMLElement) =>
          element.getAttribute("data-mention-label"),
        renderHTML: (attributes: { label?: string | null }) => {
          if (!attributes.label) {
            return {};
          }

          return {
            "data-mention-label": attributes.label,
          };
        },
      },
    };
  },

  parseHTML() {
    return [
      {
        tag: "span[data-mention]",
      },
    ];
  },

  renderHTML({
    node,
    HTMLAttributes,
  }: {
    node: MentionNode;
    HTMLAttributes: HTMLAttrs;
  }) {
    return [
      "span",
      {
        "data-mention": "",
        class: "mention text-primary-400",
        ...this.options.HTMLAttributes,
        ...HTMLAttributes,
      },
      this.options.renderLabel({
        options: this.options,
        node,
      }),
    ];
  },

  addProseMirrorPlugins() {
    return [
      Suggestion({
        editor: this.editor,
        ...this.options.suggestion,
      }),
    ];
  },
});


================================================
FILE: src/lib/index.ts
================================================
// place files you want to import through the `$lib` alias in this folder.


================================================
FILE: src/lib/server/admin-middleware.ts
================================================
import type { RequestEvent } from "@sveltejs/kit";
import { error } from "@sveltejs/kit";
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "$lib/constants/security";

export async function requireAdmin(event: RequestEvent) {
  const user = event.locals.user;

  if (!user) {
    throw error(401, "Authentication required");
  }

  if (!user.isAdmin) {
    throw error(403, "Admin access required");
  }


  const origin = event.request.headers.get('origin');
  if (origin && origin !== event.url.origin) {
    throw error(403, "Cross-origin requests not allowed");
  }


  if (event.request.method !== 'GET') {
    const csrfToken = event.request.headers.get(CSRF_HEADER_NAME);
    const csrfCookie = event.cookies.get(CSRF_COOKIE_NAME);

    if (!csrfToken || !csrfCookie || csrfToken !== csrfCookie) {
      throw error(403, "Invalid CSRF token");
    }
  }


  if (['POST', 'PUT', 'PATCH'].includes(event.request.method)) {
    const contentType = event.request.headers.get('content-type');
    if (!contentType?.includes('application/json')) {
      throw error(415, "Content type must be application/json");
    }
  }
}


================================================
FILE: src/lib/server/auth.ts
================================================
import type { User } from "@prisma/client";
import jwt from "jsonwebtoken";
import type { Cookies } from "@sveltejs/kit";
import { JWT_SECRET } from "$env/static/private";
import crypto from "node:crypto";
import { CSRF_COOKIE_NAME } from "$lib/constants/security";
const COOKIE_NAME = "session";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7;

interface Session {
  userId: number;
  exp: number;
}

export async function createSession(user: User): Promise<string> {
  const token = jwt.sign(
    { userId: user.id, exp: Math.floor(Date.now() / 1000) + SESSION_MAX_AGE },
    JWT_SECRET,
  );
  return token;
}

export async function getSession(cookies: Cookies): Promise<Session | null> {
  const token = cookies.get(COOKIE_NAME);
  if (!token) return null;

  try {
    const session = jwt.verify(token, JWT_SECRET) as Session;
    return session;
  } catch {
    return null;
  }
}

export function createSessionCookie(token: string, secure: boolean = true): string {
  const secureFlag = secure ? " Secure;" : "";
  return `${COOKIE_NAME}=${token}; Path=/; HttpOnly; SameSite=Strict;${secureFlag} Max-Age=${SESSION_MAX_AGE}`;
}

export function clearSessionCookie(): string {
  return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`;
}

export function createCsrfToken(): string {
  return crypto.randomBytes(32).toString("hex");
}

export function createCsrfCookie(token: string, secure: boolean = true): string {
  const secureFlag = secure ? " Secure;" : "";
  return `${CSRF_COOKIE_NAME}=${token}; Path=/; SameSite=Strict;${secureFlag} Max-Age=${SESSION_MAX_AGE}`;
}

export function clearCsrfCookie(): string {
  return `${CSRF_COOKIE_NAME}=; Path=/; SameSite=Strict; Max-Age=0`;
}


================================================
FILE: src/lib/server/prisma.ts
================================================
import { PrismaClient } from "@prisma/client";

const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };

export const prisma = globalForPrisma.prisma ?? new PrismaClient();

if (process.env.NODE_ENV !== "production") {
  globalForPrisma.prisma = prisma;
}


================================================
FILE: src/lib/server/services/auth.ts
================================================
import { JWT_SECRET } from "$env/static/private";
import jsonwebtoken from "jsonwebtoken";
import bcrypt from "bcryptjs";
import { prisma } from "$lib/server/prisma";
import type {
  UserSession,
  TokenPayload,
  AuthServiceInterface,
} from "$lib/types/auth";

interface UserData {
  id: number;
  username: string;
  email: string | null;
  isAdmin: boolean;
}

function toUserSession(data: UserData): UserSession {
  return {
    id: data.id,
    username: data.username,
    email: data.email,
    isAdmin: Boolean(data.isAdmin),
  };
}

class AuthService implements AuthServiceInterface {
  private static instance: AuthService;

  private constructor() {}

  static getInstance(): AuthService {
    if (!AuthService.instance) {
      AuthService.instance = new AuthService();
    }
    return AuthService.instance;
  }

  async hashPassword(password: string): Promise<string> {
    const salt = await bcrypt.genSalt(10);
    return bcrypt.hash(password, salt);
  }

  async comparePasswords(password: string, hash: string): Promise<boolean> {
    return bcrypt.compare(password, hash);
  }

  async generateToken(user: UserSession): Promise<string> {
    // Only store userId in token - fetch other data from DB when needed
    // This prevents privilege escalation if JWT secret is compromised
    const payload: TokenPayload = {
      userId: user.id,
    };

    return jsonwebtoken.sign(payload, JWT_SECRET, { expiresIn: "7d" });
  }

  async verifyToken(token: string): Promise<TokenPayload> {
    try {
      const decoded = jsonwebtoken.verify(token, JWT_SECRET) as TokenPayload;
      return decoded;
    } catch (error) {
      throw new Error("Invalid token");
    }
  }

  async createUser(
    username: string,
    email: string | null,
    password: string,
  ): Promise<UserSession> {
    const hashedPassword = await this.hashPassword(password);

    const user = await prisma.user.create({
      data: {
        username,
        email,
        passwordHash: hashedPassword,
        isAdmin: false,
      },
      select: {
        id: true,
        username: true,
        email: true,
        isAdmin: true,
      },
    });

    return toUserSession(user);
  }

  async validateUser(
    usernameOrEmail: string,
    password: string,
  ): Promise<UserSession | null> {
    // Use Prisma query builder instead of raw SQL for better portability
    const user = await prisma.user.findFirst({
      where: {
        OR: [
          { username: usernameOrEmail },
          { email: usernameOrEmail },
        ],
      },
      select: {
        id: true,
        username: true,
        email: true,
        passwordHash: true,
        isAdmin: true,
      },
    });

    if (!user) return null;

    const isValid = await this.comparePasswords(password, user.passwordHash);
    if (!isValid) return null;

    return toUserSession(user);
  }

  async findUserByIdentifier(identifier: string): Promise<UserSession | null> {
    // Use Prisma query builder instead of raw SQL for better portability
    const user = await prisma.user.findFirst({
      where: {
        OR: [
          { username: identifier },
          { email: identifier },
        ],
      },
      select: {
        id: true,
        username: true,
        email: true,
        isAdmin: true,
      },
    });

    return user ? toUserSession(user) : null;
  }

  async updatePassword(userId: number, newPassword: string): Promise<void> {
    const hashedPassword = await this.hashPassword(newPassword);

    await prisma.user.update({
      where: { id: userId },
      data: { passwordHash: hashedPassword },
    });
  }

  async createResetToken(identifier: string): Promise<string | null> {
    const user = await this.findUserByIdentifier(identifier);
    if (!user) return null;

    const resetToken = jsonwebtoken.sign({ userId: user.id }, JWT_SECRET, {
      expiresIn: "1h",
    });
    const resetTokenExp = new Date(Date.now() + 60 * 60 * 1000);

    await prisma.user.update({
      where: { id: user.id },
      data: {
        resetToken,
        resetTokenExp,
      },
    });

    return resetToken;
  }

  async validateResetToken(token: string): Promise<number | null> {
    try {
      const decoded = jsonwebtoken.verify(token, JWT_SECRET) as { userId: number };

      const user = await prisma.user.findFirst({
        where: {
          id: decoded.userId,
          resetToken: token,
          resetTokenExp: {
            gt: new Date(),
          },
        },
        select: {
          id: true,
        },
      });

      return user ? user.id : null;
    } catch {
      return null;
    }
  }

  async clearResetToken(userId: number): Promise<void> {
    await prisma.user.update({
      where: { id: userId },
      data: {
        resetToken: null,
        resetTokenExp: null,
      },
    });
  }
}

export const authService = AuthService.getInstance();


================================================
FILE: src/lib/server/services/captcha.test.ts
================================================
import { describe, it, expect } from "vitest";
import { CaptchaService } from "./captcha";

describe("CaptchaService", () => {
  it("generates and validates captchas", () => {
    const { id, text } = CaptchaService.generateCaptcha();

    expect(id).toHaveLength(32);
    expect(text).toHaveLength(6);
    expect(CaptchaService.validateCaptcha(id, text, { consume: true })).toBe(true);
    expect(CaptchaService.validateCaptcha(id, text, { consume: true })).toBe(false);
  });

  it("allows non-consuming validation", () => {
    const { id, text } = CaptchaService.generateCaptcha();

    expect(CaptchaService.validateCaptcha(id, text, { consume: false })).toBe(true);
    expect(CaptchaService.validateCaptcha(id, text, { consume: true })).toBe(true);
    expect(CaptchaService.validateCaptcha(id, text, { consume: true })).toBe(false);
  });
});


================================================
FILE: src/lib/server/services/captcha.ts
================================================
import crypto from 'crypto';

interface CaptchaEntry {
  text: string;
  createdAt: number;
}

const CAPTCHA_EXPIRY = 5 * 60 * 1000; // 5 minutes
const captchaStore = new Map<string, CaptchaEntry>();

// Cleanup expired captchas every minute
setInterval(() => {
  const now = Date.now();
  for (const [id, entry] of captchaStore.entries()) {
    if (now - entry.createdAt > CAPTCHA_EXPIRY) {
      captchaStore.delete(id);
    }
  }
}, 60 * 1000);

export class CaptchaService {
  private static readonly CHARS = '23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz';
  private static readonly LENGTH = 6;

  static generateCaptcha(): { id: string; text: string } {
    const text = Array(this.LENGTH)
      .fill(0)
      .map(() => this.CHARS[Math.floor(Math.random() * this.CHARS.length)])
      .join('');

    const id = crypto.randomBytes(16).toString('hex');

    captchaStore.set(id, {
      text,
      createdAt: Date.now(),
    });

    return { id, text };
  }

  static validateCaptcha(
    id: string,
    userInput: string,
    options: { consume?: boolean } = {},
  ): boolean {
    const entry = captchaStore.get(id);

    if (!entry) {
      return false;
    }

    // Check if expired
    if (Date.now() - entry.createdAt > CAPTCHA_EXPIRY) {
      captchaStore.delete(id);
      return false;
    }

    const matches = entry.text.toLowerCase() === userInput.toLowerCase();
    const consume = options.consume ?? true;
    if (consume || !matches) {
      captchaStore.delete(id);
    }

    return matches;
  }

  static invalidateCaptcha(id: string): void {
    captchaStore.delete(id);
  }
}


================================================
FILE: src/lib/server/services/comments.ts
================================================
import { prisma } from "$lib/server/prisma";

type MediaType = "movie" | "tv";

interface CreateCommentInput {
  userId: number;
  mediaId: number;
  mediaType: MediaType;
  content: string;
  parentId?: number | null;
}

interface CommentUser {
  username: string;
}

interface CommentCounts {
  likes: number;
  replies: number;
}

interface BaseComment {
  id: number;
  userId: number;
  mediaId: number;
  mediaType: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
  parentId: number | null;
  flagged: boolean;
  flagReason?: string | null;
  flaggedAt?: Date | null;
}

export interface CommentWithDetails extends BaseComment {
  user: CommentUser;
  _count: CommentCounts;
  isLiked?: boolean;
}

export class CommentService {
  private static instance: CommentService;

  private constructor() {}

  static getInstance(): CommentService {
    if (!CommentService.instance) {
      CommentService.instance = new CommentService();
    }
    return CommentService.instance;
  }

  async createComment(input: CreateCommentInput): Promise<BaseComment> {
    return prisma.comment.create({
      data: {
        userId: input.userId,
        mediaId: input.mediaId,
        mediaType: input.mediaType,
        content: input.content,
        parentId: input.parentId,
      },
    });
  }

  async getComments(
    mediaId: number,
    mediaType: MediaType,
    currentUserId?: number | null,
    parentId: number | null = null,
    page = 1,
    limit = 10,
  ): Promise<{ comments: CommentWithDetails[]; total: number }> {
    const skip = (page - 1) * limit;

    const [comments, total] = await Promise.all([
      prisma.comment.findMany({
        where: {
          mediaId,
          mediaType,
          parentId,
        },
        include: {
          user: {
            select: {
              username: true,
            },
          },
          _count: {
            select: {
              likes: true,
              replies: true,
            },
          },
        },
        orderBy: {
          createdAt: 'desc',
        },
        skip,
        take: limit,
      }),
      prisma.comment.count({
        where: {
          mediaId,
          mediaType,
          parentId,
        },
      }),
    ]);

    if (currentUserId) {
      const likes = await prisma.commentLike.findMany({
        where: {
          userId: currentUserId,
          commentId: {
            in: comments.map(c => c.id),
          },
        },
        select: {
          commentId: true,
        },
      });

      const likedCommentIds = new Set(likes.map(l => l.commentId));
      comments.forEach(comment => {
        (comment as CommentWithDetails).isLiked = likedCommentIds.has(comment.id);
      });
    }

    return {
      comments: comments as CommentWithDetails[],
      total,
    };
  }

  async getFlaggedComments(
    page = 1,
    limit = 10,
  ): Promise<{ comments: CommentWithDetails[]; total: number }> {
    const skip = (page - 1) * limit;

    const [comments, total] = await Promise.all([
      prisma.comment.findMany({
        where: {
          flagged: true,
        },
        include: {
          user: {
            select: {
              username: true,
            },
          },
          _count: {
            select: {
              likes: true,
              replies: true,
            },
          },
        },
        orderBy: {
          flaggedAt: 'desc',
        },
        skip,
        take: limit,
      }),
      prisma.comment.count({
        where: {
          flagged: true,
        },
      }),
    ]);

    return {
      comments: comments as CommentWithDetails[],
      total,
    };
  }

  async getReplies(
    commentId: number,
    currentUserId?: number | null,
    page = 1,
    limit = 5,
  ): Promise<{ replies: CommentWithDetails[]; total: number }> {
    const skip = (page - 1) * limit;

    const [replies, total] = await Promise.all([
      prisma.comment.findMany({
        where: {
          parentId: commentId,
        },
        include: {
          user: {
            select: {
              username: true,
            },
          },
          _count: {
            select: {
              likes: true,
              replies: true,
            },
          },
        },
        orderBy: {
          createdAt: 'desc',
        },
        skip,
        take: limit,
      }),
      prisma.comment.count({
        where: {
          parentId: commentId,
        },
      }),
    ]);

    if (currentUserId) {
      const likes = await prisma.commentLike.findMany({
        where: {
          userId: currentUserId,
          commentId: {
            in: replies.map(r => r.id),
          },
        },
        select: {
          commentId: true,
        },
      });

      const likedReplyIds = new Set(likes.map(l => l.commentId));
      replies.forEach(reply => {
        (reply as CommentWithDetails).isLiked = likedReplyIds.has(reply.id);
      });
    }

    return {
      replies: replies as CommentWithDetails[],
      total,
    };
  }

  async likeComment(userId: number, commentId: number): Promise<void> {
    await prisma.commentLike.create({
      data: {
        userId,
        commentId,
      },
    });
  }

  async unlikeComment(userId: number, commentId: number): Promise<void> {
    await prisma.commentLike.delete({
      where: {
        userId_commentId: {
          userId,
          commentId,
        },
      },
    });
  }

  async updateComment(
    commentId: number,
    userId: number,
    content: string,
  ): Promise<BaseComment> {
    return prisma.comment.update({
      where: {
        id: commentId,
        userId,
      },
      data: {
        content,
      },
    });
  }

  async deleteComment(commentId: number, userId: number): Promise<void> {
    const comment = await prisma.comment.findUnique({
      where: { id: commentId },
    });

    if (!comment) {
      throw new Error("Comment not found");
    }

    const user = await prisma.user.findUnique({
      where: { id: userId },
      select: { isAdmin: true }
    });

    if (comment.userId !== userId && !user?.isAdmin) {
      throw new Error("Unauthorized");
    }

    await prisma.comment.delete({
      where: { id: commentId },
    });
  }

  async flagComment(commentId: number, reason?: string): Promise<BaseComment> {
    return prisma.comment.update({
      where: {
        id: commentId,
      },
      data: {
        flagged: true,
        flagReason: reason || "No reason provided",
        flaggedAt: new Date(),
      },
    });
  }

  async unflagComment(commentId: number): Promise<BaseComment> {
    return prisma.comment.update({
      where: {
        id: commentId,
      },
      data: {
        flagged: false,
        flagReason: null,
        flaggedAt: null,
      },
    });
  }
}

export const commentService = CommentService.getInstance();


================================================
FILE: src/lib/server/services/db-error.ts
================================================
import { json } from "@sveltejs/kit";
import {
  PrismaClientInitializationError,
  PrismaClientKnownRequestError,
  PrismaClientValidationError,
} from "@prisma/client/runtime/library";

export interface DbErrorResponse {
  error: string;
  code?: string;
}

export function isDatabaseConnectionError(error: unknown): boolean {
  if (error instanceof PrismaClientInitializationError) {
    return true;
  }
  if (error instanceof PrismaClientKnownRequestError) {
    // P1001: Can't reach database server
    // P1002: Database server timed out
    // P1003: Database does not exist
    // P1008: Operations timed out
    // P1017: Server has closed the connection
    const connectionErrors = ['P1001', 'P1002', 'P1003', 'P1008', 'P1017'];
    return connectionErrors.includes(error.code);
  }
  return false;
}

export function handleDatabaseError(error: unknown, operation: string) {
  if (isDatabaseConnectionError(error)) {
    console.error(`Database unavailable during ${operation}:`,
      error instanceof Error ? error.message : 'Unknown error'
    );
    return json(
      { error: "Service temporarily unavailable", code: "DB_UNAVAILABLE" } as DbErrorResponse,
      { status: 503 }
    );
  }

  if (error instanceof PrismaClientKnownRequestError) {
    console.error(`Database error during ${operation}:`, error.code, error.message);

    // Handle specific known errors
    switch (error.code) {
      case 'P2002': // Unique constraint violation
        return json(
          { error: "Resource already exists", code: "DUPLICATE" } as DbErrorResponse,
          { status: 409 }
        );
      case 'P2025': // Record not found
        return json(
          { error: "Resource not found", code: "NOT_FOUND" } as DbErrorResponse,
          { status: 404 }
        );
      default:
        return json(
          { error: `Failed to ${operation}`, code: "DB_ERROR" } as DbErrorResponse,
          { status: 500 }
        );
    }
  }

  if (error instanceof PrismaClientValidationError) {
    console.error(`Validation error during ${operation}:`, error.message);
    return json(
      { error: "Invalid request data", code: "VALIDATION_ERROR" } as DbErrorResponse,
      { status: 400 }
    );
  }

  // Generic error
  console.error(`Error during ${operation}:`, error);
  return json(
    { error: `Failed to ${operation}` } as DbErrorResponse,
    { status: 500 }
  );
}


================================================
FILE: src/lib/server/services/rate-limit.ts
================================================
interface RateLimit {
  count: number;
  firstAttempt: number;
}

export class RateLimitService {
  private static readonly LOGIN_LIMIT = 5;
  private static readonly REGISTER_LIMIT = 3;
  private static readonly COMMENT_LIMIT = 5;
  private static readonly RESET_PASSWORD_LIMIT = 3;
  private static readonly LIKE_LIMIT = 30;

  private static readonly LOGIN_WINDOW = 15 * 60 * 1000; // 15 minutes
  private static readonly REGISTER_WINDOW = 60 * 60 * 1000; // 1 hour
  private static readonly COMMENT_WINDOW = 5 * 60 * 1000; // 5 minutes
  private static readonly RESET_PASSWORD_WINDOW = 60 * 60 * 1000; // 1 hour
  private static readonly LIKE_WINDOW = 60 * 1000; // 1 minute

  private static rateLimits = new Map<string, RateLimit>();

  static checkLoginLimit(ip: string): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `login:${ip}`,
      this.LOGIN_LIMIT,
      this.LOGIN_WINDOW
    );
  }

  static checkRegisterLimit(ip: string): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `register:${ip}`,
      this.REGISTER_LIMIT,
      this.REGISTER_WINDOW
    );
  }

  static checkCommentLimit(userId: number): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `comment:${userId}`,
      this.COMMENT_LIMIT,
      this.COMMENT_WINDOW
    );
  }

  static checkPasswordResetLimit(identifier: string): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `reset:${identifier}`,
      this.RESET_PASSWORD_LIMIT,
      this.RESET_PASSWORD_WINDOW
    );
  }

  static checkLikeLimit(userId: number): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `like:${userId}`,
      this.LIKE_LIMIT,
      this.LIKE_WINDOW
    );
  }

  private static checkLimit(
    key: string,
    maxAttempts: number,
    timeWindow: number
  ): {
    allowed: boolean;
    timeLeft?: number;
  } {
    const now = Date.now();
    const limit = this.rateLimits.get(key);

    if (!limit) {
      this.rateLimits.set(key, { count: 1, firstAttempt: now });
      return { allowed: true };
    }

    if (now - limit.firstAttempt >= timeWindow) {
      this.rateLimits.set(key, { count: 1, firstAttempt: now });
      return { allowed: true };
    }

    if (limit.count >= maxAttempts) {
      const timeLeft = Math.ceil(
        (timeWindow - (now - limit.firstAttempt)) / 1000
      );
      return { allowed: false, timeLeft };
    }

    limit.count++;
    this.rateLimits.set(key, limit);
    return { allowed: true };
  }

  static cleanup() {
    const now = Date.now();
    const maxWindow = Math.max(
      this.LOGIN_WINDOW,
      this.REGISTER_WINDOW,
      this.COMMENT_WINDOW,
      this.RESET_PASSWORD_WINDOW
    );

    for (const [key, limit] of this.rateLimits.entries()) {
      if (now - limit.firstAttempt >= maxWindow) {
        this.rateLimits.delete(key);
      }
    }
  }
}

setInterval(() => RateLimitService.cleanup(), 60 * 1000);

// Legacy exports for backward compatibility
class InstanceRateLimitService {
  private requestCounts = new Map<string, { count: number; timestamp: number }>();
  private readonly windowMs: number;
  private readonly maxRequests: number;

  constructor(windowMs: number = 60 * 1000, maxRequests: number = 100) {
    this.windowMs = windowMs;
    this.maxRequests = maxRequests;
  }

  checkRateLimit(ip: string): boolean {
    const now = Date.now();
    const userRequests = this.requestCounts.get(ip);

    if (!userRequests) {
      this.requestCounts.set(ip, { count: 1, timestamp: now });
      return true;
    }

    if (now - userRequests.timestamp > this.windowMs) {
      this.requestCounts.set(ip, { count: 1, timestamp: now });
      return true;
    }

    if (userRequests.count >= this.maxRequests) {
      return false;
    }

    userRequests.count++;
    return true;
  }
}

export const commentRateLimit = new InstanceRateLimitService(60 * 1000, 100);
export const authRateLimit = new InstanceRateLimitService(15 * 60 * 1000, 50);


================================================
FILE: src/lib/server/services/watchlist.ts
================================================
import { prisma } from "$lib/server/prisma";

interface PrismaError extends Error {
  code?: string;
}

export class WatchlistService {
  private static instance: WatchlistService;

  private constructor() {}

  static getInstance(): WatchlistService {
    if (!WatchlistService.instance) {
      WatchlistService.instance = new WatchlistService();
    }
    return WatchlistService.instance;
  }

  async addToWatchlist(
    userId: number,
    mediaId: number,
    mediaType: "movie" | "tv",
    title: string,
    posterPath: string | null,
    voteAverage: number,
  ) {
    try {
      const watchlistItem = await prisma.watchlist.create({
        data: {
          userId,
          mediaId,
          mediaType,
          title,
          posterPath,
          voteAverage,
        },
      });
      return watchlistItem;
    } catch (error) {
      if ((error as PrismaError).code === "P2002") {
        throw new Error("Item already in watchlist");
      }
      throw error;
    }
  }

  async removeFromWatchlist(
    userId: number,
    mediaId: number,
    mediaType: "movie" | "tv",
  ) {
    return prisma.watchlist.deleteMany({
      where: {
        userId,
        mediaId,
        mediaType,
      },
    });
  }

  async getWatchlist(userId: number) {
    return prisma.watchlist.findMany({
      where: {
        userId,
      },
      orderBy: {
        addedAt: "desc",
      },
    });
  }

  async isInWatchlist(
    userId: number,
    mediaId: number,
    mediaType: "movie" | "tv",
  ) {
    const count = await prisma.watchlist.count({
      where: {
        userId,
        mediaId,
        mediaType,
      },
    });
    return count > 0;
  }

  async getWatchlistCount(userId: number) {
    return prisma.watchlist.count({
      where: {
        userId,
      },
    });
  }
}

export const watchlistService = WatchlistService.getInstance();


================================================
FILE: src/lib/services/api-client.ts
================================================
class ApiError extends Error {
  constructor(
    public status: number,
    message: string,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

export class ApiClient {
  constructor(
    private baseUrl: string,
    private apiKey?: string,
  ) {}

  private async handleResponse<T>(response: Response): Promise<T> {
    if (!response.ok) {
      throw new ApiError(
        response.status,
        `API request failed: ${response.statusText}`,
      );
    }

    const data = await response.json();
    return data as T;
  }

  async get<T>(
    endpoint: string,
    params: Record<string, string> = {},
  ): Promise<T> {
    const searchParams = new URLSearchParams(params);
    if (this.apiKey) {
      searchParams.append("api_key", this.apiKey);
    }

    const url = `${this.baseUrl}${endpoint}?${searchParams.toString()}`;
    const response = await fetch(url);
    return this.handleResponse<T>(response);
  }
}


================================================
FILE: src/lib/services/auth.ts
================================================
import type { UserSession } from "$lib/types/auth";
import { csrfFetch } from "$lib/utils/csrf";

class AuthService {
  private static instance: AuthService;

  private constructor() {}

  static getInstance(): AuthService {
    if (!AuthService.instance) {
      AuthService.instance = new AuthService();
    }
    return AuthService.instance;
  }

  async login(usernameOrEmail: string, password: string): Promise<UserSession> {
    const response = await csrfFetch('/api/auth/login', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ usernameOrEmail, password }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to login');
    }

    return response.json();
  }

  async register(
    username: string,
    email: string | null,
    password: string,
    captchaId: string,
    captchaAnswer: string,
  ): Promise<UserSession> {
    const response = await csrfFetch('/api/auth/register', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ username, email, password, captchaId, captchaAnswer }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to register');
    }

    return response.json();
  }

  async logout(): Promise<void> {
    const response = await csrfFetch('/api/auth/logout', {
      method: 'POST',
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to logout');
    }
  }

  async getCurrentUser(): Promise<UserSession | null> {
    const response = await fetch('/api/auth/me');

    if (!response.ok) {
      if (response.status === 401) {
        return null;
      }
      const error = await response.json();
      throw new Error(error.message || 'Failed to get current user');
    }

    return response.json();
  }

  async requestPasswordReset(identifier: string): Promise<void> {
    const response = await csrfFetch('/api/auth/reset-password/request', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ identifier }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to request password reset');
    }
  }

  async resetPassword(token: string, newPassword: string): Promise<void> {
    const response = await csrfFetch('/api/auth/reset-password/reset', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ token, newPassword }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to reset password');
    }
  }
}

export const authService = AuthService.getInstance();


================================================
FILE: src/lib/services/captcha.ts
================================================
export class CaptchaService {
  private static readonly OPERATORS = ["+", "-", "*"] as const;
  private static readonly MAX_NUMBER = 10;
  private static readonly MIN_NUMBER = 1;

  static generateChallenge(): { question: string; answer: number } {
    const num1 =
      Math.floor(Math.random() * (this.MAX_NUMBER - this.MIN_NUMBER + 1)) +
      this.MIN_NUMBER;
    const num2 =
      Math.floor(Math.random() * (this.MAX_NUMBER - this.MIN_NUMBER + 1)) +
      this.MIN_NUMBER;
    const operator =
      this.OPERATORS[Math.floor(Math.random() * this.OPERATORS.length)];

    let answer: number;
    switch (operator) {
      case "+":
        answer = num1 + num2;
        break;
      case "-":
        answer = num1 - num2;
        break;
      case "*":
        answer = num1 * num2;
        break;
    }

    const question = `What is ${num1} ${operator} ${num2}?`;
    return { question, answer };
  }

  static validateAnswer(
    userAnswer: string | number,
    correctAnswer: number,
  ): boolean {
    const parsedAnswer =
      typeof userAnswer === "string" ? parseInt(userAnswer, 10) : userAnswer;
    return !isNaN(parsedAnswer) && parsedAnswer === correctAnswer;
  }
}


================================================
FILE: src/lib/services/comments.ts
================================================
import { csrfFetch } from "$lib/utils/csrf";

type MediaType = "movie" | "tv";

interface CommentUser {
  username: string;
}

interface CommentCounts {
  likes: number;
  replies: number;
}

interface BaseComment {
  id: number;
  userId: number;
  mediaId: number;
  mediaType: string;
  content: string;
  createdAt: Date;
  updatedAt: Date;
  parentId: number | null;
  flagged: boolean;
  flagReason?: string | null;
  flaggedAt?: Date | null;
}

export interface CommentWithDetails extends BaseComment {
  user: CommentUser;
  _count: CommentCounts;
  isLiked?: boolean;
}

export class CommentService {
  async createComment(
    mediaId: number,
    mediaType: MediaType,
    content: string,
    parentId?: number | null,
  ): Promise<BaseComment> {
    const response = await csrfFetch('/api/comments', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        mediaId,
        mediaType,
        content,
        parentId,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to create comment');
    }

    return response.json();
  }

  async getComments(
    mediaId: number,
    mediaType: MediaType,
    parentId: number | null = null,
    page = 1,
    limit = 10,
  ): Promise<{ comments: CommentWithDetails[]; total: number }> {
    const params = new URLSearchParams({
      mediaId: mediaId.toString(),
      mediaType,
      page: page.toString(),
      limit: limit.toString(),
    });

    if (parentId !== null) {
      params.append('parentId', parentId.toString());
    }

    const response = await fetch(`/api/comments?${params}`);

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to fetch comments');
    }

    return response.json();
  }

  async getFlaggedComments(
    page = 1,
    limit = 10,
  ): Promise<{ comments: CommentWithDetails[]; total: number }> {
    const params = new URLSearchParams({
      page: page.toString(),
      limit: limit.toString(),
    });

    const response = await fetch(`/api/comments/flagged?${params}`);

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to fetch flagged comments');
    }

    return response.json();
  }

  async getReplies(
    commentId: number,
    page = 1,
    limit = 5,
  ): Promise<{ replies: CommentWithDetails[]; total: number }> {
    const params = new URLSearchParams({
      page: page.toString(),
      limit: limit.toString(),
    });

    const response = await fetch(`/api/comments/${commentId}?${params}`);

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to fetch replies');
    }

    return response.json();
  }

  async likeComment(commentId: number): Promise<void> {
    const response = await csrfFetch('/api/comments/like', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ commentId }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to like comment');
    }
  }

  async updateComment(
    commentId: number,
    content: string,
  ): Promise<BaseComment> {
    const response = await csrfFetch(`/api/comments/${commentId}`, {
      method: 'PUT',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ content }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to update comment');
    }

    return response.json();
  }

  async deleteComment(commentId: number): Promise<void> {
    const response = await csrfFetch(`/api/comments/${commentId}`, {
      method: 'DELETE',
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to delete comment');
    }
  }

  async flagComment(commentId: number, reason?: string): Promise<void> {
    const response = await csrfFetch(`/api/comments/${commentId}/flag`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ reason }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to flag comment');
    }
  }

  async unflagComment(commentId: number): Promise<void> {
    const response = await csrfFetch(`/api/comments/${commentId}/unflag`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to unflag comment');
    }
  }
}

export const commentService = new CommentService();


================================================
FILE: src/lib/services/image.ts
================================================
import sharp from "sharp";
import { createHash } from "crypto";
import { mkdir, access, writeFile, readdir, unlink } from "fs/promises";
import { join } from "path";
import { PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

interface ImageOptions {
  width?: number;
  height?: number;
  quality?: number;
  format?: "jpeg" | "webp" | "avif" | "png";
}

interface CacheEntry {
  id: number;
  url: string;
  path: string;
  format: string;
  width: number | null;
  height: number | null;
  quality: number;
  createdAt: Date;
  accessedAt: Date;
}

const CACHE_DIR = "static/image-cache";
const DEFAULT_QUALITY = 80;
const DEFAULT_FORMAT = "webp";
const CACHE_CLEANUP_DAYS = 7;

export class ImageService {
  private static instance: ImageService;

  private constructor() {
    this.ensureCacheDir();
    this.scheduleCleanup();
  }

  static getInstance(): ImageService {
    if (!ImageService.instance) {
      ImageService.instance = new ImageService();
    }
    return ImageService.instance;
  }

  private async ensureCacheDir() {
    try {
      await access(CACHE_DIR);
    } catch {
      await mkdir(CACHE_DIR, { recursive: true });
    }
  }

  private generateCacheKey(url: string, options: ImageOptions): string {
    const hash = createHash("md5");
    hash.update(url + JSON.stringify(options));
    return hash.digest("hex");
  }

  private getCachePath(key: string, format: string): string {
    return join(CACHE_DIR, `${key}.${format}`);
  }

  private scheduleCleanup() {

    setInterval(() => this.cleanupCache(), 24 * 60 * 60 * 1000);
  }

  async cleanupCache(): Promise<void> {
    try {
      const cutoffDate = new Date();
      cutoffDate.setDate(cutoffDate.getDate() - CACHE_CLEANUP_DAYS);


      const oldEntries = await prisma.$queryRaw<CacheEntry[]>`
        SELECT * FROM image_cache
        WHERE accessedAt < ${cutoffDate}
      `;


      await Promise.all(
        oldEntries.map(async (entry) => {
          try {
            await unlink(join("static", entry.path));
            await prisma.$executeRaw`
            DELETE FROM image_cache WHERE id = ${entry.id}
          `;
          } catch (error) {
            console.error("Error cleaning up cache entry:", error);
          }
        }),
      );
    } catch (error) {
      console.error("Error during cache cleanup:", error);
    }
  }

  async optimizeImage(
    url: string,
    options: ImageOptions = {},
  ): Promise<string> {
    const {
      width,
      height,
      quality = DEFAULT_QUALITY,
      format = DEFAULT_FORMAT,
    } = options;

    const cacheKey = this.generateCacheKey(url, options);
    const cachePath = this.getCachePath(cacheKey, format);
    const relativePath = cachePath.replace("static", "");

    try {

      const cached = await prisma.$queryRaw<CacheEntry[]>`
        SELECT * FROM image_cache
        WHERE url = ${url}
          AND format = ${format}
          AND width = ${width || null}
          AND height = ${height || null}
          AND quality = ${quality}
        LIMIT 1
      `;

      if (cached.length > 0) {

        await prisma.$executeRaw`
          UPDATE image_cache
          SET accessedAt = ${new Date()}
          WHERE id = ${cached[0].id}
        `;
        return cached[0].path;
      }


      const response = await fetch(url);
      const buffer = Buffer.from(await response.arrayBuffer());

      let pipeline = sharp(buffer);


      if (width || height) {
        pipeline = pipeline.resize(width, height, {
          fit: "cover",
          withoutEnlargement: true,
        });
      }


      switch (format) {
        case "jpeg":
          pipeline = pipeline.jpeg({ quality });
          break;
        case "webp":
          pipeline = pipeline.webp({ quality });
          break;
        case "avif":
          pipeline = pipeline.avif({ quality });
          break;
        case "png":
          pipeline = pipeline.png({ quality });
          break;
      }

      const optimizedBuffer = await pipeline.toBuffer();
      await writeFile(cachePath, optimizedBuffer);


      await prisma.$executeRaw`
        INSERT INTO image_cache (url, path, format, width, height, quality, createdAt, accessedAt)
        VALUES (
          ${url},
          ${relativePath},
          ${format},
          ${width || null},
          ${height || null},
          ${quality},
          ${new Date()},
          ${new Date()}
        )
      `;

      return relativePath;
    } catch (error) {
      console.error("Image optimization error:", error);
      throw error;
    }
  }

  async generateResponsiveSet(
    url: string,
    breakpoints: number[] = [320, 640, 768, 1024, 1280],
  ): Promise<string[]> {
    const promises = breakpoints.map((width) =>
      this.optimizeImage(url, { width, format: "webp" }),
    );

    return Promise.all(promises);
  }

  async generateSrcSet(
    url: string,
    breakpoints: number[] = [320, 640, 768, 1024, 1280],
  ): Promise<string> {
    const paths = await this.generateResponsiveSet(url, breakpoints);
    return paths
      .map((path, index) => `${path} ${breakpoints[index]}w`)
      .join(", ");
  }


  isValidUrl(url: string): boolean {
    try {
      new URL(url);
      return true;
    } catch {
      return false;
    }
  }
}

export const imageService = ImageService.getInstance();


================================================
FILE: src/lib/services/providers.ts
================================================
import { browser } from "$app/environment";
import { get } from "svelte/store";
import { providerUrls } from "$lib/stores/provider-urls";

export interface Provider {
  id: string;
  name: string;
  getEmbedUrl: (
    mediaId: string | number,
    type: "movie" | "tv",
    season?: number,
    episode?: number,
  ) => string;
}

export const providers: Provider[] = [
  {
    id: "vidsrc",
    name: "VidSrc",
    getEmbedUrl: (mediaId, type, season, episode) => {
      const urls = get(providerUrls);
      if (!urls?.vidsrc) return "";

      if (type === "movie") {
        return `${urls.vidsrc}/movie/${mediaId}?autoPlay=true`;
      } else {
        if (typeof season !== "undefined" && typeof episode !== "undefined") {
          return `${urls.vidsrc}/tv/${mediaId}/${season}/${episode}?autoPlay=true&autoNext=true`;
        }
        return `${urls.vidsrc}/tv/${mediaId}?autoPlay=true`;
      }
    },
  },
  {
    id: "vidlink",
    name: "VidLink",
    getEmbedUrl: (mediaId, type, season, episode) => {
      const urls = get(providerUrls);
      if (!urls?.vidlink) return "";

      if (type === "movie") {
        return `${urls.vidlink}/movie/${mediaId}?autoplay=true&title=true`;
      } else {
        if (typeof season !== "undefined" && typeof episode !== "undefined") {
          return `${urls.vidlink}/tv/${mediaId}/${season}/${episode}?autoplay=true&title=true`;
        }
        return `${urls.vidlink}/tv/${mediaId}/1/1?autoplay=true&title=true`;
      }
    },
  },
  {
    id: "111movies",
    name: "111Movies",
    getEmbedUrl: (mediaId, type, season, episode) => {
      const urls = get(providerUrls);
      if (!urls?.movies111) return "";

      if (type === "movie") {
        return `${urls.movies111}/movie/${mediaId}`;
      } else {
        if (typeof season !== "undefined" && typeof episode !== "undefined") {
          return `${urls.movies111}/tv/${mediaId}/${season}/${episode}`;
        }
        return `${urls.movies111}/tv/${mediaId}/1/1`;
      }
    },
  },
  {
    id: "2embed",
    name: "2Embed",
    getEmbedUrl: (mediaId, type, season, episode) => {
      const urls = get(providerUrls);
      if (!urls?.embed2) return "";

      if (type === "movie") {
        return `${urls.embed2}/embed/${mediaId}`;
      } else {
        if (typeof season !== "undefined" && typeof episode !== "undefined") {
          return `${urls.embed2}/embedtv/${mediaId}&s=${season}&e=${episode}`;
        }
        return `${urls.embed2}/embedtv/${mediaId}&s=1&e=1`;
      }
    },
  },
];

export function getProvider(id: string): Provider | undefined {
  return providers.find((p) => p.id === id);
}

export function getDefaultProvider(): Provider {
  if (!browser) {
    return providers[0];
  }

  const savedProvider = localStorage.getItem("selectedProvider");
  if (savedProvider) {
    const provider = providers.find((p) => p.id === savedProvider);
    if (provider) return provider;
  }

  return providers.find((p) => p.id === "vidsrc") || providers[0];
}


================================================
FILE: src/lib/services/rate-limit.ts
================================================
interface RateLimit {
  count: number;
  firstAttempt: number;
}

export class RateLimitService {
  private static readonly COMMENT_LIMIT = 5;
  private static readonly RESET_PASSWORD_LIMIT = 3;
  private static readonly COMMENT_WINDOW = 5 * 60 * 1000;
  private static readonly RESET_PASSWORD_WINDOW = 60 * 60 * 1000;
  private static rateLimits = new Map<string, RateLimit>();

  static checkCommentLimit(userId: number): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `comment:${userId}`,
      this.COMMENT_LIMIT,
      this.COMMENT_WINDOW
    );
  }

  static checkPasswordResetLimit(identifier: string): {
    allowed: boolean;
    timeLeft?: number;
  } {
    return this.checkLimit(
      `reset:${identifier}`,
      this.RESET_PASSWORD_LIMIT,
      this.RESET_PASSWORD_WINDOW
    );
  }

  private static checkLimit(
    key: string,
    maxAttempts: number,
    timeWindow: number
  ): {
    allowed: boolean;
    timeLeft?: number;
  } {
    const now = Date.now();
    const limit = this.rateLimits.get(key);

    if (!limit) {
      this.rateLimits.set(key, { count: 1, firstAttempt: now });
      return { allowed: true };
    }

    if (now - limit.firstAttempt >= timeWindow) {
      this.rateLimits.set(key, { count: 1, firstAttempt: now });
      return { allowed: true };
    }

    if (limit.count >= maxAttempts) {
      const timeLeft = Math.ceil(
        (timeWindow - (now - limit.firstAttempt)) / 1000
      );
      return { allowed: false, timeLeft };
    }

    limit.count++;
    this.rateLimits.set(key, limit);
    return { allowed: true };
  }

  static cleanup() {
    const now = Date.now();
    const maxWindow = Math.max(this.COMMENT_WINDOW, this.RESET_PASSWORD_WINDOW);

    for (const [key, limit] of this.rateLimits.entries()) {
      if (now - limit.firstAttempt >= maxWindow) {
        this.rateLimits.delete(key);
      }
    }
  }

  static startCleanup() {
    setInterval(() => this.cleanup(), Math.max(this.COMMENT_WINDOW, this.RESET_PASSWORD_WINDOW));
  }
}


================================================
FILE: src/lib/services/release-type.ts
================================================
interface ReleaseInfo {
  releaseType: string;
  certifications: Record<string, string>;
}

const cache = new Map<string, ReleaseInfo>();

export async function getReleaseType(
  mediaId: number,
  mediaType: string,
): Promise<ReleaseInfo> {
  try {
    const cacheKey = `${mediaId}_${mediaType}`;
    if (cache.has(cacheKey)) {
      return cache.get(cacheKey)!;
    }

    const response = await fetch(`/api/release-info/${mediaType}/${mediaId}`);

    if (!response.ok) {
      return {
        releaseType: "Unknown Quality",
        certifications: {},
      };
    }

    const result: ReleaseInfo = await response.json();
    cache.set(cacheKey, result);

    return result;
  } catch (error) {
    console.error("Error fetching release type and certifications:", error);
    return {
      releaseType: "Unknown Quality",
      certifications: {},
    };
  }
}


================================================
FILE: src/lib/services/tmdb.ts
================================================
import { TMDB_API_KEY } from '$env/static/private';
import type { TMDBMovie, TMDBTVShow, TMDBResponse, TMDBGenre, TMDBMediaResponse } from '$lib/types/tmdb';

const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
const MAX_RETRIES = 2;
type QueryParams = Record<string, string | number | boolean | null | undefined>;
type HasVoteAverage = { vote_average?: number | null };

export class TMDBApiError extends Error {
  constructor(
    message: string,
    public statusCode: number,
    public isAuthError: boolean = false
  ) {
    super(message);
    this.name = 'TMDBApiError';
  }
}

export class TMDBService {
  private apiKey: string;
  private baseUrl: string;

  constructor() {
    this.apiKey = TMDB_API_KEY || '';
    this.baseUrl = TMDB_BASE_URL;
  }

  isConfigured(): boolean {
    return !!this.apiKey && this.apiKey.length > 0;
  }

  private async fetch<T>(endpoint: string, params: QueryParams = {}, retryCount = 0): Promise<T> {
    if (!this.isConfigured()) {
      throw new TMDBApiError(
        'TMDB API key is not configured. Please add TMDB_API_KEY to your .env file.',
        401,
        true
      );
    }

    const url = new URL(`${this.baseUrl}${endpoint}`);
    url.searchParams.append('api_key', this.apiKey);

    if (!params['vote_average.gte']) {
      params['vote_average.gte'] = 0.1;
    }

    for (const [key, value] of Object.entries(params)) {
      if (value !== undefined && value !== null) {
        url.searchParams.append(key, value.toString());
      }
    }

    try {
      const response = await fetch(url.toString());

      if (!response.ok) {
        if (response.status === 401) {
          throw new TMDBApiError(
            'Invalid TMDB API key. Please check your TMDB_API_KEY in .env file.',
            401,
            true
          );
        }

        if (response.status >= 400 && response.status < 500) {
          throw new TMDBApiError(
            `TMDB API client error: ${response.status} ${response.statusText}`,
            response.status
          );
        }

        if (response.status >= 500 && retryCount < MAX_RETRIES) {
          await new Promise(resolve => setTimeout(resolve, 1000 * (retryCount + 1)));
          return this.fetch<T>(endpoint, params, retryCount + 1);
        }

        throw new TMDBApiError(
          `TMDB API error: ${response.status} ${response.statusText}`,
          response.status
        );
      }

      const data = await response.json() as T;
      const results = (data as { results?: HasVoteAverage[] }).results;

      if (Array.isArray(results)) {
        (data as { results: HasVoteAverage[] }).results = results.filter(
          (item) => (item.vote_average ?? 0) > 0,
        );
      }

      return data;
    } catch (error) {
      if (error instanceof TMDBApiError) {
        throw error;
      }
      if (error instanceof Error) {
        throw new TMDBApiError(error.message, 500);
      }
      throw new TMDBApiError('Failed to fetch from TMDB API', 500);
    }
  }

  async getMovieDetails(id: number): Promise<TMDBMovie> {
    return this.fetch<TMDBMovie>(`/movie/${id}`, {
      append_to_response: 'videos'
    });
  }

  async getTVShowDetails(id: number): Promise<TMDBTVShow> {
    return this.fetch<TMDBTVShow>(`/tv/${id}`, {
      append_to_response: 'videos'
    });
  }

  async getMovieGenres(): Promise<TMDBGenre[]> {
    const response = await this.fetch<{ genres: TMDBGenre[] }>('/genre/movie/list');
    return response.genres;
  }

  async getTVGenres(): Promise<TMDBGenre[]> {
    const response = await this.fetch<{ genres: TMDBGenre[] }>('/genre/tv/list');
    return response.genres;
  }

  async searchMovies(query: string, page = 1): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>('/search/movie', {
      query,
      page,
      include_adult: false,
      language: 'en-US'
    });
  }

  async searchTVShows(query: string, page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>('/search/tv', {
      query,
      page,
      include_adult: false,
      language: 'en-US'
    });
  }

  async searchMulti(query: string, page = 1): Promise<TMDBResponse<TMDBMediaResponse>> {
    return this.fetch<TMDBResponse<TMDBMediaResponse>>('/search/multi', {
      query,
      page,
      include_adult: false,
      language: 'en-US'
    });
  }

  async discoverMovies(params: QueryParams = {}): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>('/discover/movie', {
      include_adult: false,
      language: 'en-US',
      ...params
    });
  }

  async discoverTVShows(params: QueryParams = {}): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>('/discover/tv', {
      include_adult: false,
      language: 'en-US',
      ...params
    });
  }

  async getTrending(mediaType: 'movie' | 'tv', timeWindow: 'day' | 'week' = 'week'): Promise<TMDBResponse<TMDBMediaResponse>> {
    return this.fetch<TMDBResponse<TMDBMediaResponse>>(`/trending/${mediaType}/${timeWindow}`);
  }

  async getTrendingMovies(timeWindow: 'day' | 'week' = 'week'): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>(`/trending/movie/${timeWindow}`);
  }

  async getTrendingTVShows(timeWindow: 'day' | 'week' = 'week'): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>(`/trending/tv/${timeWindow}`);
  }

  async getPopularMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>('/movie/popular', { page });
  }

  async getPopularTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>('/tv/popular', { page });
  }

  async getTopRatedMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>('/movie/top_rated', { page });
  }

  async getTopRatedTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>('/tv/top_rated', { page });
  }

  async getUpcomingMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    return this.fetch<TMDBResponse<TMDBMovie>>('/movie/upcoming', { page });
  }

  async getOnTheAirTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    return this.fetch<TMDBResponse<TMDBTVShow>>('/tv/on_the_air', { page });
  }

  getImageUrl(path: string | null, size: 'original' | 'w500' | 'w780' = 'w500'): string | null {
    if (!path) return null;
    return `https://image.tmdb.org/t/p/${size}${path}`;
  }
}


================================================
FILE: src/lib/services/watchlist.ts
================================================
import { csrfFetch } from "$lib/utils/csrf";

interface WatchlistItem {
  id: number;
  userId: number;
  mediaId: number;
  mediaType: "movie" | "tv";
  title: string;
  posterPath: string | null;
  voteAverage: number;
  addedAt: string;
}

export class WatchlistService {
  async addToWatchlist(
    mediaId: number,
    mediaType: "movie" | "tv",
    title: string,
    posterPath: string | null,
    voteAverage: number,
  ): Promise<WatchlistItem> {
    const response = await csrfFetch('/api/watchlist', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        mediaId,
        mediaType,
        title,
        posterPath,
        voteAverage,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to add to watchlist');
    }

    return response.json();
  }

  async removeFromWatchlist(
    mediaId: number,
    mediaType: "movie" | "tv",
  ): Promise<void> {
    const response = await csrfFetch('/api/watchlist', {
      method: 'DELETE',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({ mediaId, mediaType }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to remove from watchlist');
    }
  }

  async getWatchlist(): Promise<WatchlistItem[]> {
    const response = await fetch('/api/watchlist');

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to fetch watchlist');
    }

    return response.json();
  }

  async isInWatchlist(
    mediaId: number,
    mediaType: "movie" | "tv",
  ): Promise<boolean> {
    const response = await fetch(`/api/watchlist/check?mediaId=${mediaId}&mediaType=${mediaType}`);

    if (!response.ok) {
      const error = await response.json();
      throw new Error(error.message || 'Failed to check watchlist status');
    }

    const data = await response.json();
    return data.inWatchlist;
  }
}

export const watchlistService = new WatchlistService();


================================================
FILE: src/lib/shared/comment-validation.ts
================================================
export function containsUrl(text: string): boolean {
  const urlPatterns = [
    /https?:\/\/[^\s/$.?#].[^\s]*/i,
    /www\.[^\s/$.?#].[^\s]*/i,
    /[^\s/$.?#]+\.(com|net|org|edu|gov|mil|biz|info|mobi|name|aero|asia|jobs|museum|fr|uk|us|ca|eu|de|it|es)[^\s]/i
  ];

  return urlPatterns.some(pattern => pattern.test(text));
}

export function validateComment(content: string): { isValid: boolean; error?: string } {
  if (!content || content.trim().length === 0) {
    return { isValid: false, error: 'Comment cannot be empty' };
  }

  if (containsUrl(content)) {
    return { isValid: false, error: 'URLs are not allowed in comments' };
  }

  return { isValid: true };
}


================================================
FILE: src/lib/stores/auth.ts
================================================
import { writable } from "svelte/store";
import { csrfFetch } from "$lib/utils/csrf";

interface User {
  id: number;
  username: string;
  email: string | null;
  isAdmin: boolean;
}

interface AuthState {
  isAuthenticated: boolean;
  user: User | null;
  loading: boolean;
  error: string | null;
}

function createAuthStore() {
  const { subscribe, set, update } = writable<AuthState>({
    isAuthenticated: false,
    user: null,
    loading: true,
    error: null,
  });

  return {
    subscribe,

    async initialize() {
      try {
        const response = await fetch("/api/auth/me");
        if (response.ok) {
          const user = await response.json();
          set({
            isAuthenticated: true,
            user,
            loading: false,
            error: null,
          });
        } else {
          set({
            isAuthenticated: false,
            user: null,
            loading: false,
            error: null,
          });
        }
      } catch (error) {
        set({
          isAuthenticated: false,
          user: null,
          loading: false,
          error: "Failed to initialize auth",
        });
      }
    },

    async login(identifier: string, password: string) {
      try {
        const response = await csrfFetch("/api/auth/login", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ usernameOrEmail: identifier, password }),
        });

        if (!response.ok) {
          throw new Error("Login failed");
        }

        const user = await response.json();
        set({
          isAuthenticated: true,
          user,
          loading: false,
          error: null,
        });

        return true;
      } catch (error) {
        update((state: AuthState) => ({
          ...state,
          error: "Login failed",
          loading: false,
        }));
        return false;
      }
    },

    async register(
      username: string,
      email: string | null,
      password: string,
      captchaId: string,
      captchaAnswer: string,
    ) {
      try {
        const response = await csrfFetch("/api/auth/register", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ username, email, password, captchaId, captchaAnswer }),
        });

        if (!response.ok) {
          throw new Error("Registration failed");
        }

        const user = await response.json();
        set({
          isAuthenticated: true,
          user,
          loading: false,
          error: null,
        });

        return true;
      } catch (error) {
        update((state: AuthState) => ({
          ...state,
          error: "Registration failed",
          loading: false,
        }));
        return false;
      }
    },

    async logout() {
      try {
        await csrfFetch("/api/auth/logout", { method: "POST" });
      } finally {
        set({
          isAuthenticated: false,
          user: null,
          loading: false,
          error: null,
        });
      }
    },

    clearError() {
      update((state: AuthState) => ({ ...state, error: null }));
    },
  };
}

export const authStore = createAuthStore();


================================================
FILE: src/lib/stores/comments.ts
================================================
import { writable } from "svelte/store";
import type { Comment } from "$lib/types/comments";
import { csrfFetch } from "$lib/utils/csrf";

interface CommentStore {
  comments: Comment[];
  total: number;
  loading: boolean;
  error: string | null;
}

function createCommentsStore() {
  const { subscribe, set, update } = writable<CommentStore>({
    comments: [],
    total: 0,
    loading: false,
    error: null,
  });

  return {
    subscribe,
    async getComments(
      mediaType: string,
      mediaId: number,
      page = 1,
      limit = 10,
    ) {
      update((state) => ({ ...state, loading: true, error: null }));
      try {
        const response = await fetch(
          `/api/comments?mediaType=${mediaType}&mediaId=${mediaId}&page=${page}&limit=${limit}`,
        );
        if (!response.ok) throw new Error("Failed to fetch comments");
        const data = await response.json();
        update((state) => ({
          ...state,
          comments: data.comments,
          total: data.total,
          loading: false,
        }));
        return data;
      } catch (error) {
        update((state) => ({
          ...state,
          error:
            error instanceof Error ? error.message : "Failed to fetch comments",
          loading: false,
        }));
        throw error;
      }
    },

    async addComment({
      mediaType,
      mediaId,
      content,
      rating,
      parentId,
    }: {
      mediaType: string;
      mediaId: number;
      content: string;
      rating?: number;
      parentId?: number;
    }) {
      try {
        const response = await csrfFetch("/api/comments", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            mediaType,
            mediaId,
            content,
            rating,
            parentId,
          }),
        });
        if (!response.ok) throw new Error("Failed to add comment");
        const newComment = await response.json();
        update((state) => ({
          ...state,
          comments: [newComment, ...state.comments],
          total: state.total + 1,
        }));
        return newComment;
      } catch (error) {
        throw error;
      }
    },

    async toggleLike(commentId: number) {
      try {
        const response = await csrfFetch(`/api/comments/like`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ commentId }),
        });
        if (!response.ok) throw new Error("Failed to toggle like");
        const { liked } = await response.json();
        update((state) => ({
          ...state,
          comments: state.comments.map((comment) => {
            if (comment.id === commentId) {
              return {
                ...comment,
                isLiked: liked,
                _count: {
                  ...comment._count,
                  likes: comment._count.likes + (liked ? 1 : -1),
                },
              };
            }
            return comment;
          }),
        }));
      } catch (error) {
        throw error;
      }
    },

    async updateComment(commentId: number, content: string) {
      try {
        const response = await csrfFetch(`/api/comments/${commentId}`, {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ content }),
        });
        if (!response.ok) throw new Error("Failed to update comment");
        const updatedComment = await response.json();
        update((state) => ({
          ...state,
          comments: state.comments.map((comment) =>
            comment.id === commentId ? updatedComment : comment,
          ),
        }));
        return updatedComment;
      } catch (error) {
        throw error;
      }
    },

    async deleteComment(commentId: number) {
      try {
        const response = await csrfFetch(`/api/comments/${commentId}`, {
          method: "DELETE",
        });
        if (!response.ok) throw new Error("Failed to delete comment");
        update((state) => ({
          ...state,
          comments: state.comments.filter(
            (comment) => comment.id !== commentId,
          ),
          total: state.total - 1,
        }));
      } catch (error) {
        throw error;
      }
    },
  };
}

export const commentsStore = createCommentsStore();


================================================
FILE: src/lib/stores/filters.ts
================================================
import { writable, derived, get } from "svelte/store";
import type {
  FilterState,
  FilterOptions,
  Genre,
  SortBy,
  SortOrder,
  WatchStatus,
} from "$lib/types/filters";
import { defaultFilterState, createQueryString } from "$lib/types/filters";
import { goto } from "$app/navigation";
import { page } from "$app/stores";
import type { Page } from "@sveltejs/kit";

const sortByValues: SortBy[] = ["popularity", "rating", "release_date", "title"];
const sortOrderValues: SortOrder[] = ["asc", "desc"];
const watchStatusValues: WatchStatus[] = ["all", "watching", "completed", "planned"];

function isSortBy(value: string): value is SortBy {
  return sortByValues.includes(value as SortBy);
}

function isSortOrder(value: string): value is SortOrder {
  return sortOrderValues.includes(value as SortOrder);
}

function isWatchStatus(value: string): value is WatchStatus {
  return watchStatusValues.includes(value as WatchStatus);
}

function createFilterStore() {
  const { subscribe, set, update } = writable<FilterState>(defaultFilterState);

  return {
    subscribe,

    initialize(genres: Genre[]) {
      update((state) => ({
        ...state,
        availableGenres: genres,
      }));
    },

    setFromQueryString(queryString: string) {
      const params = new URLSearchParams(queryString);

      update((state) => {
        const newState = { ...state };


        const query = params.get("query");
        if (query) newState.query = query;


        const genres = params.get("genres");
        if (genres) {
          newState.genres = genres.split(",").map(Number);
        }


        const year = params.get("year");
        if (year) newState.year = parseInt(year);


        const rating = params.get("rating");
        if (rating) newState.rating = parseInt(rating);


        const sortBy = params.get("sortBy");
        if (sortBy && isSortBy(sortBy)) newState.sortBy = sortBy;

        const sortOrder = params.get("sortOrder");
        if (sortOrder && isSortOrder(sortOrder)) newState.sortOrder = sortOrder;


        const watchStatus = params.get("watchStatus");
        if (watchStatus && isWatchStatus(watchStatus)) {
          newState.watchStatus = watchStatus;
        }


        const page = params.get("page");
        if (page) newState.page = parseInt(page);

        const limit = params.get("limit");
        if (limit) newState.limit = parseInt(limit);

        return newState;
      });
    },

    updateFilters(filters: Partial<FilterOptions>, navigate = true) {
      update((state) => {
        const newState = {
          ...state,
          ...filters,

          page: "page" in filters ? filters.page || 1 : 1,
        };

        if (navigate) {
          const currentPage = get(page);
          const baseUrl = currentPage.url.pathname;
          const queryString = createQueryString(newState);
          goto(`${baseUrl}?${queryString}`, { replaceState: true });
        }

        return newState;
      });
    },

    reset() {
      update((state) => ({
        ...defaultFilterState,
        availableGenres: state.availableGenres,
      }));

      const currentPage = get(page);
      const baseUrl = currentPage.url.pathname;
      goto(baseUrl, { replaceState: true });
    },

    setResults(totalResults: number, totalPages: number) {
      update((state) => ({
        ...state,
        totalResults,
        totalPages,
      }));
    },
  };
}

export const filters = createFilterStore();


export const activeFilters = derived(filters, ($filters) => {
  const active: string[] = [];

  if ($filters.query) {
    active.push(`Search: "${$filters.query}"`);
  }

  if ($filters.genres && $filters.genres.length > 0) {
    const genreNames = $filters.genres
      .map((id) => $filters.availableGenres.find((g) => g.id === id)?.name)
      .filter(Boolean);
    if (genreNames.length > 0) {
      active.push(`Genres: ${genreNames.join(", ")}`);
    }
  }

  if ($filters.year) {
    active.push(`Year: ${$filters.year}`);
  }

  if ($filters.rating) {
    active.push(`Rating: ${$filters.rating}+`);
  }

  if ($filters.watchStatus && $filters.watchStatus !== "all") {
    active.push(`Status: ${$filters.watchStatus}`);
  }

  if ($filters.sortBy && $filters.sortBy !== "popularity") {
    active.push(`Sort: ${$filters.sortBy} (${$filters.sortOrder})`);
  }

  return active;
});

export const hasActiveFilters = derived(
  activeFilters,
  ($activeFilters) => $activeFilters.length > 0,
);

export const currentPage = derived(filters, ($filters) => $filters.page || 1);

export const totalPages = derived(filters, ($filters) => $filters.totalPages);

export const selectedGenres = derived(filters, ($filters) => {
  if (!$filters.genres) return [];
  return $filters.availableGenres.filter((genre) =>
    $filters.genres?.includes(genre.id),
  );
});


================================================
FILE: src/lib/stores/provider-urls.ts
================================================
import { writable } from "svelte/store";

interface ProviderUrls {
  vidsrc: string;
  vidlink: string;
  movies111: string;
  embed2: string;
}

export const providerUrls = writable<ProviderUrls | null>(null);

export async function loadProviderUrls() {
  if (typeof window === "undefined") return;

  try {
    const response = await fetch("/api/providers");
    const urls = await response.json();
    providerUrls.set(urls);
  } catch (error) {
    console.error("Failed to load provider URLs:", error);
  }
}


================================================
FILE: src/lib/stores/toast.ts
================================================
import { writable } from "svelte/store";

export interface Toast {
  id: string;
  type: "success" | "error" | "info" | "warning";
  message: string;
  duration?: number;
}

function createToastStore() {
  const { subscribe, update } = writable<Toast[]>([]);

  function addToast(toast: Omit<Toast, "id">) {
    const id = Math.random().toString(36).substring(2);
    const duration = toast.duration || 5000;

    update((toasts) => [...toasts, { ...toast, id }]);


    setTimeout(() => {
      removeToast(id);
    }, duration);
  }

  function removeToast(id: string) {
    update((toasts) => toasts.filter((t) => t.id !== id));
  }

  function success(message: string, duration?: number) {
    addToast({ type: "success", message, duration });
  }

  function error(message: string, duration?: number) {
    addToast({ type: "error", message, duration });
  }

  function info(message: string, duration?: number) {
    addToast({ type: "info", message, duration });
  }

  function warning(message: string, duration?: number) {
    addToast({ type: "warning", message, duration });
  }

  return {
    subscribe,
    success,
    error,
    info,
    warning,
    remove: removeToast,
  };
}

export const toastStore = createToastStore();


================================================
FILE: src/lib/stores/watchlist.ts
================================================
import { writable } from "svelte/store";
import { authStore } from "./auth";
import { get } from "svelte/store";
import { csrfFetch } from "$lib/utils/csrf";

interface WatchlistItem {
  id: number;
  mediaId: number;
  mediaType: string;
  title: string;
  posterPath: string | null;
  voteAverage: number;
  addedAt: string;
}

interface WatchlistStore {
  items: WatchlistItem[];
  total: number;
  loading: boolean;
  error: string | null;
}

function createWatchlistStore() {
  const { subscribe, set, update } = writable<WatchlistStore>({
    items: [],
    total: 0,
    loading: false,
    error: null,
  });

  return {
    subscribe,
    async getWatchlist() {
      const auth = get(authStore);
      if (!auth.user) {
        update(state => ({ ...state, items: [], total: 0, loading: false, error: null }));
        return { items: [], total: 0 };
      }

      update((state) => ({ ...state, loading: true, error: null }));
      try {
        const response = await fetch("/api/watchlist");
        if (!response.ok) throw new Error("Failed to fetch watchlist");
        const data = await response.json();
        update((state) => ({
          ...state,
          items: data.items,
          total: data.total,
          loading: false,
        }));
        return data;
      } catch (error) {
        update((state) => ({
          ...state,
          error:
            error instanceof Error
              ? error.message
              : "Failed to fetch watchlist",
          loading: false,
        }));
        throw error;
      }
    },

    async addToWatchlist(
      mediaId: number,
      mediaType: string,
      title: string,
      posterPath: string | null,
      voteAverage: number,
    ) {
      const auth = get(authStore);
      if (!auth.user) {
        throw new Error("Must be logged in to add to watchlist");
      }

      try {
        const response = await csrfFetch("/api/watchlist", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({
            mediaId,
            mediaType,
            title,
            posterPath,
            voteAverage,
          }),
        });
        if (!response.ok) throw new Error("Failed to add to watchlist");
        const newItem = await response.json();
        update((state) => ({
          ...state,
          items: [newItem, ...state.items],
          total: state.total + 1,
        }));
        return newItem;
      } catch (error) {
        throw error;
      }
    },

    async removeFromWatchlist(mediaId: number, mediaType: string) {
      const auth = get(authStore);
      if (!auth.user) {
        throw new Error("Must be logged in to remove from watchlist");
      }

      try {
        const response = await csrfFetch("/api/watchlist", {
          method: "DELETE",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ mediaId, mediaType }),
        });
        if (!response.ok) throw new Error("Failed to remove from watchlist");
        update((state) => ({
          ...state,
          items: state.items.filter(
            (item) =>
              !(item.mediaId === mediaId && item.mediaType === mediaType),
          ),
          total: state.total - 1,
        }));
      } catch (error) {
        throw error;
      }
    },

    async isInWatchlist(mediaId: number, mediaType: string) {
      const auth = get(authStore);
      if (!auth.user) {
        return false;
      }

      try {
        const response = await fetch(
          `/api/watchlist/check?mediaId=${mediaId}&mediaType=${mediaType}`,
        );
        if (!response.ok) throw new Error("Failed to check watchlist status");
        const { inWatchlist } = await response.json();
        return inWatchlist;
      } catch (error) {
        throw error;
      }
    },

    reset() {
      set({
        items: [],
        total: 0,
        loading: false,
        error: null,
      });
    }
  };
}

export const watchlistStore = createWatchlistStore();


================================================
FILE: src/lib/types/auth.ts
================================================
export interface UserSession {
  id: number;
  username: string;
  email: string | null;
  isAdmin: boolean;
}

export interface TokenPayload {
  userId: number;
}

export interface AuthServiceInterface {
  hashPassword(password: string): Promise<string>;
  comparePasswords(password: string, hash: string): Promise<boolean>;
  generateToken(user: UserSession): Promise<string>;
  verifyToken(token: string): Promise<TokenPayload>;
  createUser(
    username: string,
    email: string | null,
    password: string,
  ): Promise<UserSession>;
  validateUser(
    usernameOrEmail: string,
    password: string,
  ): Promise<UserSession | null>;
  findUserByIdentifier(identifier: string): Promise<UserSession | null>;
  updatePassword(userId: number, newPassword: string): Promise<void>;
  createResetToken(identifier: string): Promise<string | null>;
  validateResetToken(token: string): Promise<number | null>;
  clearResetToken(userId: number): Promise<void>;
}


export async function verifyToken(token: string): Promise<TokenPayload> {
  const { authService } = await import("$lib/server/services/auth");
  return authService.verifyToken(token);
}


export const userSelect = {
  id: true,
  username: true,
  email: true,
  isAdmin: true,
} as const;


================================================
FILE: src/lib/types/comments.ts
================================================
export interface Comment {
  id: number;
  content: string;
  userId: number;
  mediaId: number;
  mediaType: string;
  rating: number | null;
  parentId: number | null;
  createdAt: string;
  updatedAt: string;
  user: {
    username: string;
  };
  _count: {
    likes: number;
    replies: number;
  };
  isLiked?: boolean;
  flagged?: boolean;
  flagReason?: string | null;
  flaggedAt?: string | null;
}

export interface CommentResponse {
  comments: Comment[];
  total: number;
}


================================================
FILE: src/lib/types/filters.ts
================================================
export type SortOrder = "asc" | "desc";
export type SortBy = "popularity" | "rating" | "release_date" | "title";
export type MediaType = "movie" | "tv";
export type WatchStatus = "all" | "watching" | "completed" | "planned";

export interface Genre {
  id: number;
  name: string;
}

export interface FilterOptions {
  query?: string;
  genres?: number[];
  year?: number;
  rating?: number;
  sortBy?: SortBy;
  sortOrder?: SortOrder;
  watchStatus?: WatchStatus;
  page?: number;
  limit?: number;
}

export interface FilterState extends FilterOptions {
  availableGenres: Genre[];
  totalResults: number;
  totalPages: number;
}

export const defaultFilterState: FilterState = {
  query: "",
  genres: [],
  sortBy: "popularity",
  sortOrder: "desc",
  watchStatus: "all",
  page: 1,
  limit: 20,
  availableGenres: [],
  totalResults: 0,
  totalPages: 0,
};

export const sortOptions: { value: SortBy; label: string }[] = [
  { value: "popularity", label: "Popularity" },
  { value: "rating", label: "Rating" },
  { value: "release_date", label: "Release Date" },
  { value: "title", label: "Title" },
];

export const watchStatusOptions: { value: WatchStatus; label: string }[] = [
  { value: "all", label: "All" },
  { value: "watching", label: "Watching" },
  { value: "completed", label: "Completed" },
  { value: "planned", label: "Plan to Watch" },
];

export const yearOptions: { value: number; label: string }[] = (() => {
  const currentYear = new Date().getFullYear();
  const years = [];
  for (let year = currentYear; year >= 1900; year--) {
    years.push({ value: year, label: year.toString() });
  }
  return years;
})();

export const ratingOptions: { value: number; label: string }[] = [
  { value: 9, label: "9+ Rating" },
  { value: 8, label: "8+ Rating" },
  { value: 7, label: "7+ Rating" },
  { value: 6, label: "6+ Rating" },
  { value: 5, label: "5+ Rating" },
  { value: 0, label: "All Ratings" },
];

export function createQueryString(filters: FilterOptions): string {
  const params = new URLSearchParams();

  if (filters.query) {
    params.set("query", filters.query);
  }

  if (filters.genres && filters.genres.length > 0) {
    params.set("genres", filters.genres.join(","));
  }

  if (filters.year) {
    params.set("year", filters.year.toString());
  }

  if (filters.rating) {
    params.set("rating", filters.rating.toString());
  }

  if (filters.sortBy) {
    params.set("sortBy", filters.sortBy);
  }

  if (filters.sortOrder) {
    params.set("sortOrder", filters.sortOrder);
  }

  if (filters.watchStatus && filters.watchStatus !== "all") {
    params.set("watchStatus", filters.watchStatus);
  }

  if (filters.page && filters.page > 1) {
    params.set("page", filters.page.toString());
  }

  if (filters.limit && filters.limit !== 20) {
    params.set("limit", filters.limit.toString());
  }

  return params.toString();
}

export function parseQueryString(queryString: string): FilterOptions {
  const params = new URLSearchParams(queryString);
  const filters: FilterOptions = {};

  const query = params.get("query");
  if (query) {
    filters.query = query;
  }

  const genres = params.get("genres");
  if (genres) {
    filters.genres = genres.split(",").map(Number);
  }

  const year = params.get("year");
  if (year
Download .txt
gitextract_mk17is37/

├── .env.example
├── .github/
│   └── FUNDING.yml
├── .gitignore
├── .npmrc
├── CONTRIBUTING.md
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── docker-compose.yml
├── init.sql
├── package.json
├── postcss.config.js
├── prisma/
│   └── schema.prisma
├── server.js
├── src/
│   ├── app.css
│   ├── app.d.ts
│   ├── app.html
│   ├── env.d.ts
│   ├── hooks.server.ts
│   ├── lib/
│   │   ├── components/
│   │   │   ├── Captcha.svelte
│   │   │   ├── CommentForm.svelte
│   │   │   ├── CommentList.svelte
│   │   │   ├── CommentModeration.svelte
│   │   │   ├── EmojiPicker.svelte
│   │   │   ├── EpisodeSelector.svelte
│   │   │   ├── Hero.svelte
│   │   │   ├── Image.svelte
│   │   │   ├── MediaCard.svelte
│   │   │   ├── MediaFilters.svelte
│   │   │   ├── MediaPlayer.svelte
│   │   │   ├── MentionList.svelte
│   │   │   ├── Navbar.svelte
│   │   │   ├── NextEpisode.svelte
│   │   │   ├── Pagination.svelte
│   │   │   ├── ReplyForm.svelte
│   │   │   ├── RichTextEditor.svelte
│   │   │   ├── Toast.svelte
│   │   │   ├── VideoPlayer.svelte
│   │   │   └── WatchlistButton.svelte
│   │   ├── constants/
│   │   │   └── security.ts
│   │   ├── extensions/
│   │   │   └── mention.ts
│   │   ├── index.ts
│   │   ├── server/
│   │   │   ├── admin-middleware.ts
│   │   │   ├── auth.ts
│   │   │   ├── prisma.ts
│   │   │   └── services/
│   │   │       ├── auth.ts
│   │   │       ├── captcha.test.ts
│   │   │       ├── captcha.ts
│   │   │       ├── comments.ts
│   │   │       ├── db-error.ts
│   │   │       ├── rate-limit.ts
│   │   │       └── watchlist.ts
│   │   ├── services/
│   │   │   ├── api-client.ts
│   │   │   ├── auth.ts
│   │   │   ├── captcha.ts
│   │   │   ├── comments.ts
│   │   │   ├── image.ts
│   │   │   ├── providers.ts
│   │   │   ├── rate-limit.ts
│   │   │   ├── release-type.ts
│   │   │   ├── tmdb.ts
│   │   │   └── watchlist.ts
│   │   ├── shared/
│   │   │   └── comment-validation.ts
│   │   ├── stores/
│   │   │   ├── auth.ts
│   │   │   ├── comments.ts
│   │   │   ├── filters.ts
│   │   │   ├── provider-urls.ts
│   │   │   ├── toast.ts
│   │   │   └── watchlist.ts
│   │   ├── types/
│   │   │   ├── auth.ts
│   │   │   ├── comments.ts
│   │   │   ├── filters.ts
│   │   │   ├── provider.ts
│   │   │   └── tmdb.ts
│   │   └── utils/
│   │       └── csrf.ts
│   └── routes/
│       ├── +layout.server.ts
│       ├── +layout.svelte
│       ├── +layout.ts
│       ├── +page.server.ts
│       ├── +page.svelte
│       ├── admin/
│       │   └── moderation/
│       │       ├── +page.server.ts
│       │       └── +page.svelte
│       ├── api/
│       │   ├── auth/
│       │   │   ├── login/
│       │   │   │   └── +server.ts
│       │   │   ├── logout/
│       │   │   │   └── +server.ts
│       │   │   ├── me/
│       │   │   │   └── +server.ts
│       │   │   ├── register/
│       │   │   │   └── +server.ts
│       │   │   └── reset-password/
│       │   │       ├── request/
│       │   │       │   └── +server.ts
│       │   │       └── reset/
│       │   │           └── +server.ts
│       │   ├── captcha/
│       │   │   └── +server.ts
│       │   ├── comments/
│       │   │   ├── +server.ts
│       │   │   ├── [id]/
│       │   │   │   ├── +server.ts
│       │   │   │   ├── flag/
│       │   │   │   │   └── +server.ts
│       │   │   │   └── unflag/
│       │   │   │       └── +server.ts
│       │   │   ├── flagged/
│       │   │   │   └── +server.ts
│       │   │   └── like/
│       │   │       └── +server.ts
│       │   ├── image/
│       │   │   └── [...path]/
│       │   │       └── +server.ts
│       │   ├── movies/
│       │   │   ├── +server.ts
│       │   │   └── trending/
│       │   │       └── +server.ts
│       │   ├── providers/
│       │   │   └── +server.ts
│       │   ├── release-info/
│       │   │   └── [type]/
│       │   │       └── [id]/
│       │   │           └── +server.ts
│       │   ├── tv/
│       │   │   ├── +server.ts
│       │   │   ├── [id]/
│       │   │   │   ├── season/
│       │   │   │   │   └── [season]/
│       │   │   │   │       └── +server.ts
│       │   │   │   └── seasons/
│       │   │   │       └── +server.ts
│       │   │   └── trending/
│       │   │       └── +server.ts
│       │   ├── users/
│       │   │   └── search/
│       │   │       └── +server.ts
│       │   └── watchlist/
│       │       ├── +server.ts
│       │       └── check/
│       │           └── +server.ts
│       ├── dmca/
│       │   └── +page.svelte
│       ├── login/
│       │   └── +page.svelte
│       ├── media/
│       │   └── [id]/
│       │       ├── +page.server.ts
│       │       └── +page.svelte
│       ├── movies/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       ├── register/
│       │   └── +page.svelte
│       ├── reset-password/
│       │   ├── +page.svelte
│       │   └── [token]/
│       │       └── +page.svelte
│       ├── search/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       ├── tv/
│       │   ├── +page.server.ts
│       │   └── +page.svelte
│       └── watchlist/
│           ├── +page.server.ts
│           └── +page.svelte
├── svelte.config.js
├── tailwind.config.js
├── tsconfig.json
└── vite.config.ts
Download .txt
SYMBOL INDEX (302 symbols across 64 files)

FILE: src/app.d.ts
  type Error (line 6) | interface Error {
  type Locals (line 10) | interface Locals {
  type PageData (line 18) | interface PageData {
  type Platform (line 26) | interface Platform {}
  type ProcessEnv (line 30) | interface ProcessEnv {

FILE: src/env.d.ts
  type ImportMetaEnv (line 3) | interface ImportMetaEnv {
  type ImportMeta (line 20) | interface ImportMeta {

FILE: src/hooks.server.ts
  constant ADMIN_RATE_LIMIT (line 9) | const ADMIN_RATE_LIMIT = 100;
  constant ADMIN_RATE_WINDOW (line 10) | const ADMIN_RATE_WINDOW = 5 * 60 * 1000;
  constant CSRF_MAX_AGE (line 12) | const CSRF_MAX_AGE = 60 * 60 * 24 * 7;
  function checkAdminRateLimit (line 14) | function checkAdminRateLimit(ip: string): boolean {
  function setSecurityHeaders (line 46) | function setSecurityHeaders(response: Response, nonce: string): void {

FILE: src/lib/constants/security.ts
  constant CSRF_COOKIE_NAME (line 1) | const CSRF_COOKIE_NAME = "csrf";
  constant CSRF_HEADER_NAME (line 2) | const CSRF_HEADER_NAME = "x-csrf-token";

FILE: src/lib/extensions/mention.ts
  type HTMLAttrs (line 4) | type HTMLAttrs = Record<string, string | number | boolean | null | undef...
  type MentionNode (line 6) | interface MentionNode {
  type MentionOptions (line 13) | interface MentionOptions {
  type CommandProps (line 19) | interface CommandProps {
  method addOptions (line 27) | addOptions() {
  method addAttributes (line 72) | addAttributes() {
  method parseHTML (line 105) | parseHTML() {
  method renderHTML (line 113) | renderHTML({
  method addProseMirrorPlugins (line 135) | addProseMirrorPlugins() {

FILE: src/lib/server/admin-middleware.ts
  function requireAdmin (line 5) | async function requireAdmin(event: RequestEvent) {

FILE: src/lib/server/auth.ts
  constant COOKIE_NAME (line 7) | const COOKIE_NAME = "session";
  constant SESSION_MAX_AGE (line 8) | const SESSION_MAX_AGE = 60 * 60 * 24 * 7;
  type Session (line 10) | interface Session {
  function createSession (line 15) | async function createSession(user: User): Promise<string> {
  function getSession (line 23) | async function getSession(cookies: Cookies): Promise<Session | null> {
  function createSessionCookie (line 35) | function createSessionCookie(token: string, secure: boolean = true): str...
  function clearSessionCookie (line 40) | function clearSessionCookie(): string {
  function createCsrfToken (line 44) | function createCsrfToken(): string {
  function createCsrfCookie (line 48) | function createCsrfCookie(token: string, secure: boolean = true): string {
  function clearCsrfCookie (line 53) | function clearCsrfCookie(): string {

FILE: src/lib/server/services/auth.ts
  type UserData (line 11) | interface UserData {
  function toUserSession (line 18) | function toUserSession(data: UserData): UserSession {
  class AuthService (line 27) | class AuthService implements AuthServiceInterface {
    method constructor (line 30) | private constructor() {}
    method getInstance (line 32) | static getInstance(): AuthService {
    method hashPassword (line 39) | async hashPassword(password: string): Promise<string> {
    method comparePasswords (line 44) | async comparePasswords(password: string, hash: string): Promise<boolea...
    method generateToken (line 48) | async generateToken(user: UserSession): Promise<string> {
    method verifyToken (line 58) | async verifyToken(token: string): Promise<TokenPayload> {
    method createUser (line 67) | async createUser(
    method validateUser (line 92) | async validateUser(
    method findUserByIdentifier (line 121) | async findUserByIdentifier(identifier: string): Promise<UserSession | ...
    method updatePassword (line 141) | async updatePassword(userId: number, newPassword: string): Promise<voi...
    method createResetToken (line 150) | async createResetToken(identifier: string): Promise<string | null> {
    method validateResetToken (line 170) | async validateResetToken(token: string): Promise<number | null> {
    method clearResetToken (line 193) | async clearResetToken(userId: number): Promise<void> {

FILE: src/lib/server/services/captcha.ts
  type CaptchaEntry (line 3) | interface CaptchaEntry {
  constant CAPTCHA_EXPIRY (line 8) | const CAPTCHA_EXPIRY = 5 * 60 * 1000;
  class CaptchaService (line 21) | class CaptchaService {
    method generateCaptcha (line 25) | static generateCaptcha(): { id: string; text: string } {
    method validateCaptcha (line 41) | static validateCaptcha(
    method invalidateCaptcha (line 67) | static invalidateCaptcha(id: string): void {

FILE: src/lib/server/services/comments.ts
  type MediaType (line 3) | type MediaType = "movie" | "tv";
  type CreateCommentInput (line 5) | interface CreateCommentInput {
  type CommentUser (line 13) | interface CommentUser {
  type CommentCounts (line 17) | interface CommentCounts {
  type BaseComment (line 22) | interface BaseComment {
  type CommentWithDetails (line 36) | interface CommentWithDetails extends BaseComment {
  class CommentService (line 42) | class CommentService {
    method constructor (line 45) | private constructor() {}
    method getInstance (line 47) | static getInstance(): CommentService {
    method createComment (line 54) | async createComment(input: CreateCommentInput): Promise<BaseComment> {
    method getComments (line 66) | async getComments(
    method getFlaggedComments (line 136) | async getFlaggedComments(
    method getReplies (line 179) | async getReplies(
    method likeComment (line 243) | async likeComment(userId: number, commentId: number): Promise<void> {
    method unlikeComment (line 252) | async unlikeComment(userId: number, commentId: number): Promise<void> {
    method updateComment (line 263) | async updateComment(
    method deleteComment (line 279) | async deleteComment(commentId: number, userId: number): Promise<void> {
    method flagComment (line 302) | async flagComment(commentId: number, reason?: string): Promise<BaseCom...
    method unflagComment (line 315) | async unflagComment(commentId: number): Promise<BaseComment> {

FILE: src/lib/server/services/db-error.ts
  type DbErrorResponse (line 8) | interface DbErrorResponse {
  function isDatabaseConnectionError (line 13) | function isDatabaseConnectionError(error: unknown): boolean {
  function handleDatabaseError (line 29) | function handleDatabaseError(error: unknown, operation: string) {

FILE: src/lib/server/services/rate-limit.ts
  type RateLimit (line 1) | interface RateLimit {
  class RateLimitService (line 6) | class RateLimitService {
    method checkLoginLimit (line 21) | static checkLoginLimit(ip: string): {
    method checkRegisterLimit (line 32) | static checkRegisterLimit(ip: string): {
    method checkCommentLimit (line 43) | static checkCommentLimit(userId: number): {
    method checkPasswordResetLimit (line 54) | static checkPasswordResetLimit(identifier: string): {
    method checkLikeLimit (line 65) | static checkLikeLimit(userId: number): {
    method checkLimit (line 76) | private static checkLimit(
    method cleanup (line 109) | static cleanup() {
  class InstanceRateLimitService (line 129) | class InstanceRateLimitService {
    method constructor (line 134) | constructor(windowMs: number = 60 * 1000, maxRequests: number = 100) {
    method checkRateLimit (line 139) | checkRateLimit(ip: string): boolean {

FILE: src/lib/server/services/watchlist.ts
  type PrismaError (line 3) | interface PrismaError extends Error {
  class WatchlistService (line 7) | class WatchlistService {
    method constructor (line 10) | private constructor() {}
    method getInstance (line 12) | static getInstance(): WatchlistService {
    method addToWatchlist (line 19) | async addToWatchlist(
    method removeFromWatchlist (line 47) | async removeFromWatchlist(
    method getWatchlist (line 61) | async getWatchlist(userId: number) {
    method isInWatchlist (line 72) | async isInWatchlist(
    method getWatchlistCount (line 87) | async getWatchlistCount(userId: number) {

FILE: src/lib/services/api-client.ts
  class ApiError (line 1) | class ApiError extends Error {
    method constructor (line 2) | constructor(
  class ApiClient (line 11) | class ApiClient {
    method constructor (line 12) | constructor(
    method handleResponse (line 17) | private async handleResponse<T>(response: Response): Promise<T> {
    method get (line 29) | async get<T>(

FILE: src/lib/services/auth.ts
  class AuthService (line 4) | class AuthService {
    method constructor (line 7) | private constructor() {}
    method getInstance (line 9) | static getInstance(): AuthService {
    method login (line 16) | async login(usernameOrEmail: string, password: string): Promise<UserSe...
    method register (line 33) | async register(
    method logout (line 56) | async logout(): Promise<void> {
    method getCurrentUser (line 67) | async getCurrentUser(): Promise<UserSession | null> {
    method requestPasswordReset (line 81) | async requestPasswordReset(identifier: string): Promise<void> {
    method resetPassword (line 96) | async resetPassword(token: string, newPassword: string): Promise<void> {

FILE: src/lib/services/captcha.ts
  class CaptchaService (line 1) | class CaptchaService {
    method generateChallenge (line 6) | static generateChallenge(): { question: string; answer: number } {
    method validateAnswer (line 33) | static validateAnswer(

FILE: src/lib/services/comments.ts
  type MediaType (line 3) | type MediaType = "movie" | "tv";
  type CommentUser (line 5) | interface CommentUser {
  type CommentCounts (line 9) | interface CommentCounts {
  type BaseComment (line 14) | interface BaseComment {
  type CommentWithDetails (line 28) | interface CommentWithDetails extends BaseComment {
  class CommentService (line 34) | class CommentService {
    method createComment (line 35) | async createComment(
    method getComments (line 62) | async getComments(
    method getFlaggedComments (line 90) | async getFlaggedComments(
    method getReplies (line 109) | async getReplies(
    method likeComment (line 129) | async likeComment(commentId: number): Promise<void> {
    method updateComment (line 144) | async updateComment(
    method deleteComment (line 164) | async deleteComment(commentId: number): Promise<void> {
    method flagComment (line 175) | async flagComment(commentId: number, reason?: string): Promise<void> {
    method unflagComment (line 190) | async unflagComment(commentId: number): Promise<void> {

FILE: src/lib/services/image.ts
  type ImageOptions (line 9) | interface ImageOptions {
  type CacheEntry (line 16) | interface CacheEntry {
  constant CACHE_DIR (line 28) | const CACHE_DIR = "static/image-cache";
  constant DEFAULT_QUALITY (line 29) | const DEFAULT_QUALITY = 80;
  constant DEFAULT_FORMAT (line 30) | const DEFAULT_FORMAT = "webp";
  constant CACHE_CLEANUP_DAYS (line 31) | const CACHE_CLEANUP_DAYS = 7;
  class ImageService (line 33) | class ImageService {
    method constructor (line 36) | private constructor() {
    method getInstance (line 41) | static getInstance(): ImageService {
    method ensureCacheDir (line 48) | private async ensureCacheDir() {
    method generateCacheKey (line 56) | private generateCacheKey(url: string, options: ImageOptions): string {
    method getCachePath (line 62) | private getCachePath(key: string, format: string): string {
    method scheduleCleanup (line 66) | private scheduleCleanup() {
    method cleanupCache (line 71) | async cleanupCache(): Promise<void> {
    method optimizeImage (line 100) | async optimizeImage(
    method generateResponsiveSet (line 192) | async generateResponsiveSet(
    method generateSrcSet (line 203) | async generateSrcSet(
    method isValidUrl (line 214) | isValidUrl(url: string): boolean {

FILE: src/lib/services/providers.ts
  type Provider (line 5) | interface Provider {
  function getProvider (line 87) | function getProvider(id: string): Provider | undefined {
  function getDefaultProvider (line 91) | function getDefaultProvider(): Provider {

FILE: src/lib/services/rate-limit.ts
  type RateLimit (line 1) | interface RateLimit {
  class RateLimitService (line 6) | class RateLimitService {
    method checkCommentLimit (line 13) | static checkCommentLimit(userId: number): {
    method checkPasswordResetLimit (line 24) | static checkPasswordResetLimit(identifier: string): {
    method checkLimit (line 35) | private static checkLimit(
    method cleanup (line 68) | static cleanup() {
    method startCleanup (line 79) | static startCleanup() {

FILE: src/lib/services/release-type.ts
  type ReleaseInfo (line 1) | interface ReleaseInfo {
  function getReleaseType (line 8) | async function getReleaseType(

FILE: src/lib/services/tmdb.ts
  constant TMDB_BASE_URL (line 4) | const TMDB_BASE_URL = 'https://api.themoviedb.org/3';
  constant MAX_RETRIES (line 5) | const MAX_RETRIES = 2;
  type QueryParams (line 6) | type QueryParams = Record<string, string | number | boolean | null | und...
  type HasVoteAverage (line 7) | type HasVoteAverage = { vote_average?: number | null };
  class TMDBApiError (line 9) | class TMDBApiError extends Error {
    method constructor (line 10) | constructor(
  class TMDBService (line 20) | class TMDBService {
    method constructor (line 24) | constructor() {
    method isConfigured (line 29) | isConfigured(): boolean {
    method fetch (line 33) | private async fetch<T>(endpoint: string, params: QueryParams = {}, ret...
    method getMovieDetails (line 106) | async getMovieDetails(id: number): Promise<TMDBMovie> {
    method getTVShowDetails (line 112) | async getTVShowDetails(id: number): Promise<TMDBTVShow> {
    method getMovieGenres (line 118) | async getMovieGenres(): Promise<TMDBGenre[]> {
    method getTVGenres (line 123) | async getTVGenres(): Promise<TMDBGenre[]> {
    method searchMovies (line 128) | async searchMovies(query: string, page = 1): Promise<TMDBResponse<TMDB...
    method searchTVShows (line 137) | async searchTVShows(query: string, page = 1): Promise<TMDBResponse<TMD...
    method searchMulti (line 146) | async searchMulti(query: string, page = 1): Promise<TMDBResponse<TMDBM...
    method discoverMovies (line 155) | async discoverMovies(params: QueryParams = {}): Promise<TMDBResponse<T...
    method discoverTVShows (line 163) | async discoverTVShows(params: QueryParams = {}): Promise<TMDBResponse<...
    method getTrending (line 171) | async getTrending(mediaType: 'movie' | 'tv', timeWindow: 'day' | 'week...
    method getTrendingMovies (line 175) | async getTrendingMovies(timeWindow: 'day' | 'week' = 'week'): Promise<...
    method getTrendingTVShows (line 179) | async getTrendingTVShows(timeWindow: 'day' | 'week' = 'week'): Promise...
    method getPopularMovies (line 183) | async getPopularMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    method getPopularTVShows (line 187) | async getPopularTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    method getTopRatedMovies (line 191) | async getTopRatedMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    method getTopRatedTVShows (line 195) | async getTopRatedTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    method getUpcomingMovies (line 199) | async getUpcomingMovies(page = 1): Promise<TMDBResponse<TMDBMovie>> {
    method getOnTheAirTVShows (line 203) | async getOnTheAirTVShows(page = 1): Promise<TMDBResponse<TMDBTVShow>> {
    method getImageUrl (line 207) | getImageUrl(path: string | null, size: 'original' | 'w500' | 'w780' = ...

FILE: src/lib/services/watchlist.ts
  type WatchlistItem (line 3) | interface WatchlistItem {
  class WatchlistService (line 14) | class WatchlistService {
    method addToWatchlist (line 15) | async addToWatchlist(
    method removeFromWatchlist (line 44) | async removeFromWatchlist(
    method getWatchlist (line 62) | async getWatchlist(): Promise<WatchlistItem[]> {
    method isInWatchlist (line 73) | async isInWatchlist(

FILE: src/lib/shared/comment-validation.ts
  function containsUrl (line 1) | function containsUrl(text: string): boolean {
  function validateComment (line 11) | function validateComment(content: string): { isValid: boolean; error?: s...

FILE: src/lib/stores/auth.ts
  type User (line 4) | interface User {
  type AuthState (line 11) | interface AuthState {
  function createAuthStore (line 18) | function createAuthStore() {

FILE: src/lib/stores/comments.ts
  type CommentStore (line 5) | interface CommentStore {
  function createCommentsStore (line 12) | function createCommentsStore() {

FILE: src/lib/stores/filters.ts
  function isSortBy (line 19) | function isSortBy(value: string): value is SortBy {
  function isSortOrder (line 23) | function isSortOrder(value: string): value is SortOrder {
  function isWatchStatus (line 27) | function isWatchStatus(value: string): value is WatchStatus {
  function createFilterStore (line 31) | function createFilterStore() {

FILE: src/lib/stores/provider-urls.ts
  type ProviderUrls (line 3) | interface ProviderUrls {
  function loadProviderUrls (line 12) | async function loadProviderUrls() {

FILE: src/lib/stores/toast.ts
  type Toast (line 3) | interface Toast {
  function createToastStore (line 10) | function createToastStore() {

FILE: src/lib/stores/watchlist.ts
  type WatchlistItem (line 6) | interface WatchlistItem {
  type WatchlistStore (line 16) | interface WatchlistStore {
  function createWatchlistStore (line 23) | function createWatchlistStore() {

FILE: src/lib/types/auth.ts
  type UserSession (line 1) | interface UserSession {
  type TokenPayload (line 8) | interface TokenPayload {
  type AuthServiceInterface (line 12) | interface AuthServiceInterface {
  function verifyToken (line 34) | async function verifyToken(token: string): Promise<TokenPayload> {

FILE: src/lib/types/comments.ts
  type Comment (line 1) | interface Comment {
  type CommentResponse (line 24) | interface CommentResponse {

FILE: src/lib/types/filters.ts
  type SortOrder (line 1) | type SortOrder = "asc" | "desc";
  type SortBy (line 2) | type SortBy = "popularity" | "rating" | "release_date" | "title";
  type MediaType (line 3) | type MediaType = "movie" | "tv";
  type WatchStatus (line 4) | type WatchStatus = "all" | "watching" | "completed" | "planned";
  type Genre (line 6) | interface Genre {
  type FilterOptions (line 11) | interface FilterOptions {
  type FilterState (line 23) | interface FilterState extends FilterOptions {
  function createQueryString (line 74) | function createQueryString(filters: FilterOptions): string {
  function parseQueryString (line 116) | function parseQueryString(queryString: string): FilterOptions {

FILE: src/lib/types/provider.ts
  type MediaProvider (line 1) | interface MediaProvider {
  type ProviderOptions (line 20) | interface ProviderOptions {
  type StreamingQuality (line 29) | interface StreamingQuality {
  type ProviderResponse (line 38) | interface ProviderResponse {

FILE: src/lib/types/tmdb.ts
  type TMDBReleaseDatesResponse (line 1) | interface TMDBReleaseDatesResponse {
  type TMDBReleaseDateResult (line 6) | interface TMDBReleaseDateResult {
  type TMDBReleaseDate (line 11) | interface TMDBReleaseDate {
  type TMDBMediaResponse (line 19) | interface TMDBMediaResponse {
  type TMDBResponse (line 35) | interface TMDBResponse<T> {
  type TMDBVideoResponse (line 42) | interface TMDBVideoResponse {
  type TMDBVideo (line 47) | interface TMDBVideo {
  type TMDBMovie (line 56) | interface TMDBMovie extends TMDBMediaResponse {
  type TMDBTVShow (line 62) | interface TMDBTVShow extends TMDBMediaResponse {
  type TMDBGenre (line 68) | interface TMDBGenre {
  type TMDBWatchProvider (line 73) | interface TMDBWatchProvider {
  type TMDBWatchProviderRegion (line 80) | interface TMDBWatchProviderRegion {
  type TMDBWatchProvidersResponse (line 87) | interface TMDBWatchProvidersResponse {

FILE: src/lib/utils/csrf.ts
  function getCookie (line 3) | function getCookie(name: string): string | null {
  function getCsrfToken (line 11) | function getCsrfToken(): string | null {
  function withCsrfHeaders (line 15) | function withCsrfHeaders(init: RequestInit = {}): RequestInit {
  function csrfFetch (line 28) | async function csrfFetch(

FILE: src/routes/api/auth/login/+server.ts
  function POST (line 9) | async function POST({ request, getClientAddress }: RequestEvent) {

FILE: src/routes/api/auth/logout/+server.ts
  function POST (line 5) | async function POST(_event: RequestEvent) {

FILE: src/routes/api/auth/me/+server.ts
  function GET (line 6) | async function GET({ cookies }: RequestEvent) {

FILE: src/routes/api/auth/register/+server.ts
  constant PASSWORD_MIN_LENGTH (line 10) | const PASSWORD_MIN_LENGTH = 8;
  constant PASSWORD_REGEX (line 11) | const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
  function POST (line 13) | async function POST({ request, getClientAddress }: RequestEvent) {

FILE: src/routes/api/auth/reset-password/request/+server.ts
  function POST (line 6) | async function POST({ request }: RequestEvent) {

FILE: src/routes/api/auth/reset-password/reset/+server.ts
  constant PASSWORD_MIN_LENGTH (line 6) | const PASSWORD_MIN_LENGTH = 8;
  constant PASSWORD_REGEX (line 7) | const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
  function POST (line 9) | async function POST({ request }: RequestEvent) {

FILE: src/routes/api/captcha/+server.ts
  function GET (line 12) | async function GET({ getClientAddress }: RequestEvent) {
  function POST (line 22) | async function POST({ request }: RequestEvent) {

FILE: src/routes/api/comments/+server.ts
  function validateNumericInput (line 20) | function validateNumericInput(value: string | null, min: number, max: nu...
  function checkQueryComplexity (line 27) | function checkQueryComplexity(params: URLSearchParams): boolean {
  function GET (line 37) | async function GET({ url, cookies, getClientAddress }: RequestEvent) {
  function POST (line 92) | async function POST({ request, cookies, getClientAddress }: RequestEvent) {
  function DELETE (line 131) | async function DELETE({ url, cookies, getClientAddress }: RequestEvent) {
  function checkRateLimit (line 155) | function checkRateLimit(ip: string): boolean {

FILE: src/routes/api/comments/[id]/+server.ts
  function DELETE (line 6) | async function DELETE(event: RequestEvent) {

FILE: src/routes/api/comments/[id]/flag/+server.ts
  function POST (line 12) | async function POST({ params, request, cookies }: RequestEvent) {
  function DELETE (line 68) | async function DELETE({ params, cookies }: RequestEvent) {

FILE: src/routes/api/comments/[id]/unflag/+server.ts
  function POST (line 6) | async function POST(event: RequestEvent) {

FILE: src/routes/api/comments/flagged/+server.ts
  function GET (line 8) | async function GET(event: RequestEvent) {

FILE: src/routes/api/comments/like/+server.ts
  function POST (line 13) | async function POST({ request, cookies }: RequestEvent) {
  function GET (line 96) | async function GET({ url, cookies }: RequestEvent) {

FILE: src/routes/api/image/[...path]/+server.ts
  constant ALLOWED_SIZES (line 6) | const ALLOWED_SIZES = new Set([
  constant PATH_PATTERN (line 12) | const PATH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
  function GET (line 14) | async function GET({ params, fetch }: RequestEvent) {

FILE: src/routes/api/movies/+server.ts
  function GET (line 6) | async function GET({ fetch, url }: RequestEvent) {

FILE: src/routes/api/movies/trending/+server.ts
  function GET (line 5) | async function GET({ fetch }: RequestEvent) {

FILE: src/routes/api/providers/+server.ts
  function validateProviderUrl (line 5) | function validateProviderUrl(url: string | undefined): string | null {
  function GET (line 20) | async function GET() {

FILE: src/routes/api/release-info/[type]/[id]/+server.ts
  type ReleaseDate (line 5) | interface ReleaseDate {
  type ReleaseDateResult (line 13) | interface ReleaseDateResult {
  type WatchProviderRegion (line 18) | interface WatchProviderRegion {
  function GET (line 24) | async function GET({ params, fetch }: RequestEvent) {

FILE: src/routes/api/tv/+server.ts
  function GET (line 6) | async function GET({ fetch, url }: RequestEvent) {

FILE: src/routes/api/tv/[id]/season/[season]/+server.ts
  type Episode (line 5) | interface Episode {
  type SeasonResponse (line 17) | interface SeasonResponse {
  function GET (line 21) | async function GET({ params, fetch }: RequestEvent) {

FILE: src/routes/api/tv/[id]/seasons/+server.ts
  function GET (line 5) | async function GET({ params, fetch }: RequestEvent) {

FILE: src/routes/api/tv/trending/+server.ts
  function GET (line 5) | async function GET({ fetch }: RequestEvent) {

FILE: src/routes/api/users/search/+server.ts
  function escapeLikePattern (line 5) | function escapeLikePattern(pattern: string): string {
  function GET (line 9) | async function GET({ url, locals }: RequestEvent) {

FILE: src/routes/api/watchlist/+server.ts
  function GET (line 22) | async function GET({ locals }: RequestEvent) {
  function POST (line 36) | async function POST({ request, locals }: RequestEvent) {
  function DELETE (line 69) | async function DELETE({ request, locals }: RequestEvent) {

FILE: src/routes/api/watchlist/check/+server.ts
  function GET (line 6) | async function GET({ url, locals }: RequestEvent) {

FILE: src/routes/media/[id]/+page.server.ts
  type PageData (line 6) | interface PageData {

FILE: src/routes/movies/+page.server.ts
  function fetchFilteredMovies (line 41) | async function fetchFilteredMovies(filters: FilterOptions) {

FILE: src/routes/tv/+page.server.ts
  function fetchFilteredShows (line 41) | async function fetchFilteredShows(filters: FilterOptions) {
Condensed preview — 127 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (335K chars).
[
  {
    "path": ".env.example",
    "chars": 574,
    "preview": "# Database\nDATABASE_URL=\"mysql://user:password@localhost:3306/streamium\"\n\n# Authentication (REQUIRED - use a strong rand"
  },
  {
    "path": ".github/FUNDING.yml",
    "chars": 17,
    "preview": "ko_fi: kasterby\n\n"
  },
  {
    "path": ".gitignore",
    "chars": 413,
    "preview": "# Environment\n.env\n.env.*\n!.env.example\n\n# Dependencies\nnode_modules\npackage-lock.json\n\n# Build output\n.svelte-kit\nbuild"
  },
  {
    "path": ".npmrc",
    "chars": 19,
    "preview": "engine-strict=true\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 2312,
    "preview": "# Contributing to Streamium\n\nThank you for your interest in contributing to Streamium! This document provides guidelines"
  },
  {
    "path": "Dockerfile",
    "chars": 406,
    "preview": "FROM node:18-slim\n\nWORKDIR /app\n\nRUN apt-get update && apt-get install -y \\\n    openssl \\\n    default-mysql-client \\\n   "
  },
  {
    "path": "LICENSE",
    "chars": 1085,
    "preview": "MIT License\n\nCopyright (c) 2024 https://github.com/gmonarque\n\nPermission is hereby granted, free of charge, to any perso"
  },
  {
    "path": "README.md",
    "chars": 1939,
    "preview": "# Streamium\n\nA SvelteKit streaming UI that embeds content from third-party providers and uses TMDB for movie/TV metadata"
  },
  {
    "path": "SECURITY.md",
    "chars": 1371,
    "preview": "# Security Policy\n\n## Reporting a Vulnerability\n\nIf you discover a security vulnerability within Streamium, please send "
  },
  {
    "path": "docker-compose.yml",
    "chars": 1081,
    "preview": "services:\n  web:\n    build: .\n    ports:\n      - \"5173:5173\"\n    volumes:\n      - .:/app\n      - /app/node_modules\n    e"
  },
  {
    "path": "init.sql",
    "chars": 216,
    "preview": "GRANT CREATE ON *.* TO 'user'@'%';\nCREATE DATABASE IF NOT EXISTS `streamium_shadow`;\nGRANT ALL PRIVILEGES ON `streamium_"
  },
  {
    "path": "package.json",
    "chars": 1837,
    "preview": "{\n  \"name\": \"streamium\",\n  \"version\": \"0.0.1\",\n  \"type\": \"module\",\n  \"scripts\": {\n    \"dev\": \"vite dev\",\n    \"build\": \"v"
  },
  {
    "path": "postcss.config.js",
    "chars": 81,
    "preview": "export default {\n  plugins: {\n    tailwindcss: {},\n    autoprefixer: {},\n  },\n};\n"
  },
  {
    "path": "prisma/schema.prisma",
    "chars": 2554,
    "preview": "generator client {\n  provider = \"prisma-client-js\"\n}\n\ndatasource db {\n  provider = \"mysql\"\n  url      = env(\"DATABASE_UR"
  },
  {
    "path": "server.js",
    "chars": 145,
    "preview": "// Production server wrapper\nimport('./build/index.js').catch(err => {\n    console.error('Failed to import app:', err);\n"
  },
  {
    "path": "src/app.css",
    "chars": 2439,
    "preview": "@tailwind base;\n@tailwind components;\n@tailwind utilities;\n\n@layer base {\n  html {\n    @apply antialiased;\n  }\n\n  body {"
  },
  {
    "path": "src/app.d.ts",
    "chars": 688,
    "preview": "/// <reference types=\"@sveltejs/kit\" />\n\n// See https://kit.svelte.dev/docs/types#app\ndeclare global {\n  namespace App {"
  },
  {
    "path": "src/app.html",
    "chars": 357,
    "preview": "<!doctype html>\n<html lang=\"en\">\n  <head>\n    <meta charset=\"utf-8\" />\n    <link rel=\"icon\" href=\"%sveltekit.assets%/fav"
  },
  {
    "path": "src/env.d.ts",
    "chars": 1233,
    "preview": "/// <reference types=\"vite/client\" />\n\ninterface ImportMetaEnv {\n  readonly VITE_TMDB_API_KEY: string;\n  readonly TMDB_A"
  },
  {
    "path": "src/hooks.server.ts",
    "chars": 6386,
    "preview": "import type { Handle } from \"@sveltejs/kit\";\nimport { getSession, createCsrfToken } from \"$lib/server/auth\";\nimport { pr"
  },
  {
    "path": "src/lib/components/Captcha.svelte",
    "chars": 8486,
    "preview": "<script lang=\"ts\">\n  import { createEventDispatcher } from 'svelte';\n  import { onMount } from 'svelte';\n  import { csrf"
  },
  {
    "path": "src/lib/components/CommentForm.svelte",
    "chars": 4169,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import RichTextEditor from './RichTextEditor.svelte';\n  import "
  },
  {
    "path": "src/lib/components/CommentList.svelte",
    "chars": 14385,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import { browser } from '$app/environment';\n  import { authStor"
  },
  {
    "path": "src/lib/components/CommentModeration.svelte",
    "chars": 3059,
    "preview": "<script lang=\"ts\">\n  import { page } from '$app/stores';\n  import { commentsStore } from '$lib/stores/comments';\n  impor"
  },
  {
    "path": "src/lib/components/EmojiPicker.svelte",
    "chars": 2585,
    "preview": "<script lang=\"ts\">\n  import { createEventDispatcher, onDestroy } from 'svelte';\n  import data from '@emoji-mart/data';\n "
  },
  {
    "path": "src/lib/components/EpisodeSelector.svelte",
    "chars": 4687,
    "preview": "<script lang=\"ts\">\n  import { createEventDispatcher } from 'svelte';\n\n  interface Season {\n    season_number: number;\n  "
  },
  {
    "path": "src/lib/components/Hero.svelte",
    "chars": 2639,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import type { TMDBMediaResponse } from '$lib/types/tmdb';\n\n  ex"
  },
  {
    "path": "src/lib/components/Image.svelte",
    "chars": 1393,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n\n  export let src: string | null;\n  export let alt: string;\n  exp"
  },
  {
    "path": "src/lib/components/MediaCard.svelte",
    "chars": 3663,
    "preview": "<script lang=\"ts\">\n  import Image from './Image.svelte';\n  import WatchlistButton from './WatchlistButton.svelte';\n  imp"
  },
  {
    "path": "src/lib/components/MediaFilters.svelte",
    "chars": 3378,
    "preview": "<script lang=\"ts\">\n  import { createEventDispatcher } from 'svelte';\n\n  export let type: 'movie' | 'tv';\n  export let se"
  },
  {
    "path": "src/lib/components/MediaPlayer.svelte",
    "chars": 1511,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n\n  export let src: string;\n  export let title: string;\n  export l"
  },
  {
    "path": "src/lib/components/MentionList.svelte",
    "chars": 1861,
    "preview": "<script lang=\"ts\">\n  import { createEventDispatcher } from 'svelte';\n\n  export let items: { id: number; username: string"
  },
  {
    "path": "src/lib/components/Navbar.svelte",
    "chars": 7098,
    "preview": "<script lang=\"ts\">\n  import { page } from '$app/stores';\n  import { onMount } from 'svelte';\n  import { authStore } from"
  },
  {
    "path": "src/lib/components/NextEpisode.svelte",
    "chars": 2977,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n\n  export let mediaId: number;\n  export let currentSeason: number"
  },
  {
    "path": "src/lib/components/Pagination.svelte",
    "chars": 3134,
    "preview": "<script lang=\"ts\">\n  import { filters } from '$lib/stores/filters';\n\n  export let totalPages: number;\n  export let curre"
  },
  {
    "path": "src/lib/components/ReplyForm.svelte",
    "chars": 3921,
    "preview": "<script lang=\"ts\">\n  import RichTextEditor from './RichTextEditor.svelte';\n  import { validateComment } from '$lib/share"
  },
  {
    "path": "src/lib/components/RichTextEditor.svelte",
    "chars": 4947,
    "preview": "<script lang=\"ts\">\n  import { onMount, onDestroy, createEventDispatcher } from 'svelte';\n  import { Editor } from '@tipt"
  },
  {
    "path": "src/lib/components/Toast.svelte",
    "chars": 2468,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import { fade, fly } from 'svelte/transition';\n  import { toast"
  },
  {
    "path": "src/lib/components/VideoPlayer.svelte",
    "chars": 3404,
    "preview": "<script lang=\"ts\">\n  import { onMount, onDestroy } from 'svelte';\n  import { browser } from '$app/environment';\n  import"
  },
  {
    "path": "src/lib/components/WatchlistButton.svelte",
    "chars": 2391,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import { browser } from '$app/environment';\n  import { watchlis"
  },
  {
    "path": "src/lib/constants/security.ts",
    "chars": 88,
    "preview": "export const CSRF_COOKIE_NAME = \"csrf\";\nexport const CSRF_HEADER_NAME = \"x-csrf-token\";\n"
  },
  {
    "path": "src/lib/extensions/mention.ts",
    "chars": 3127,
    "preview": "import { Extension, type Editor, type Range } from \"@tiptap/core\";\nimport Suggestion, { type SuggestionOptions } from \"@"
  },
  {
    "path": "src/lib/index.ts",
    "chars": 75,
    "preview": "// place files you want to import through the `$lib` alias in this folder.\n"
  },
  {
    "path": "src/lib/server/admin-middleware.ts",
    "chars": 1127,
    "preview": "import type { RequestEvent } from \"@sveltejs/kit\";\nimport { error } from \"@sveltejs/kit\";\nimport { CSRF_COOKIE_NAME, CSR"
  },
  {
    "path": "src/lib/server/auth.ts",
    "chars": 1703,
    "preview": "import type { User } from \"@prisma/client\";\nimport jwt from \"jsonwebtoken\";\nimport type { Cookies } from \"@sveltejs/kit\""
  },
  {
    "path": "src/lib/server/prisma.ts",
    "chars": 276,
    "preview": "import { PrismaClient } from \"@prisma/client\";\n\nconst globalForPrisma = globalThis as unknown as { prisma?: PrismaClient"
  },
  {
    "path": "src/lib/server/services/auth.ts",
    "chars": 4892,
    "preview": "import { JWT_SECRET } from \"$env/static/private\";\nimport jsonwebtoken from \"jsonwebtoken\";\nimport bcrypt from \"bcryptjs\""
  },
  {
    "path": "src/lib/server/services/captcha.test.ts",
    "chars": 851,
    "preview": "import { describe, it, expect } from \"vitest\";\nimport { CaptchaService } from \"./captcha\";\n\ndescribe(\"CaptchaService\", ("
  },
  {
    "path": "src/lib/server/services/captcha.ts",
    "chars": 1620,
    "preview": "import crypto from 'crypto';\n\ninterface CaptchaEntry {\n  text: string;\n  createdAt: number;\n}\n\nconst CAPTCHA_EXPIRY = 5 "
  },
  {
    "path": "src/lib/server/services/comments.ts",
    "chars": 6884,
    "preview": "import { prisma } from \"$lib/server/prisma\";\n\ntype MediaType = \"movie\" | \"tv\";\n\ninterface CreateCommentInput {\n  userId:"
  },
  {
    "path": "src/lib/server/services/db-error.ts",
    "chars": 2396,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport {\n  PrismaClientInitializationError,\n  PrismaClientKnownRequestError,\n  Pri"
  },
  {
    "path": "src/lib/server/services/rate-limit.ts",
    "chars": 4075,
    "preview": "interface RateLimit {\n  count: number;\n  firstAttempt: number;\n}\n\nexport class RateLimitService {\n  private static reado"
  },
  {
    "path": "src/lib/server/services/watchlist.ts",
    "chars": 1876,
    "preview": "import { prisma } from \"$lib/server/prisma\";\n\ninterface PrismaError extends Error {\n  code?: string;\n}\n\nexport class Wat"
  },
  {
    "path": "src/lib/services/api-client.ts",
    "chars": 936,
    "preview": "class ApiError extends Error {\n  constructor(\n    public status: number,\n    message: string,\n  ) {\n    super(message);\n"
  },
  {
    "path": "src/lib/services/auth.ts",
    "chars": 2952,
    "preview": "import type { UserSession } from \"$lib/types/auth\";\nimport { csrfFetch } from \"$lib/utils/csrf\";\n\nclass AuthService {\n  "
  },
  {
    "path": "src/lib/services/captcha.ts",
    "chars": 1189,
    "preview": "export class CaptchaService {\n  private static readonly OPERATORS = [\"+\", \"-\", \"*\"] as const;\n  private static readonly "
  },
  {
    "path": "src/lib/services/comments.ts",
    "chars": 4931,
    "preview": "import { csrfFetch } from \"$lib/utils/csrf\";\n\ntype MediaType = \"movie\" | \"tv\";\n\ninterface CommentUser {\n  username: stri"
  },
  {
    "path": "src/lib/services/image.ts",
    "chars": 5349,
    "preview": "import sharp from \"sharp\";\nimport { createHash } from \"crypto\";\nimport { mkdir, access, writeFile, readdir, unlink } fro"
  },
  {
    "path": "src/lib/services/providers.ts",
    "chars": 3006,
    "preview": "import { browser } from \"$app/environment\";\nimport { get } from \"svelte/store\";\nimport { providerUrls } from \"$lib/store"
  },
  {
    "path": "src/lib/services/rate-limit.ts",
    "chars": 2046,
    "preview": "interface RateLimit {\n  count: number;\n  firstAttempt: number;\n}\n\nexport class RateLimitService {\n  private static reado"
  },
  {
    "path": "src/lib/services/release-type.ts",
    "chars": 870,
    "preview": "interface ReleaseInfo {\n  releaseType: string;\n  certifications: Record<string, string>;\n}\n\nconst cache = new Map<string"
  },
  {
    "path": "src/lib/services/tmdb.ts",
    "chars": 6560,
    "preview": "import { TMDB_API_KEY } from '$env/static/private';\nimport type { TMDBMovie, TMDBTVShow, TMDBResponse, TMDBGenre, TMDBMe"
  },
  {
    "path": "src/lib/services/watchlist.ts",
    "chars": 2151,
    "preview": "import { csrfFetch } from \"$lib/utils/csrf\";\n\ninterface WatchlistItem {\n  id: number;\n  userId: number;\n  mediaId: numbe"
  },
  {
    "path": "src/lib/shared/comment-validation.ts",
    "chars": 675,
    "preview": "export function containsUrl(text: string): boolean {\n  const urlPatterns = [\n    /https?:\\/\\/[^\\s/$.?#].[^\\s]*/i,\n    /w"
  },
  {
    "path": "src/lib/stores/auth.ts",
    "chars": 3246,
    "preview": "import { writable } from \"svelte/store\";\nimport { csrfFetch } from \"$lib/utils/csrf\";\n\ninterface User {\n  id: number;\n  "
  },
  {
    "path": "src/lib/stores/comments.ts",
    "chars": 4404,
    "preview": "import { writable } from \"svelte/store\";\nimport type { Comment } from \"$lib/types/comments\";\nimport { csrfFetch } from \""
  },
  {
    "path": "src/lib/stores/filters.ts",
    "chars": 4835,
    "preview": "import { writable, derived, get } from \"svelte/store\";\nimport type {\n  FilterState,\n  FilterOptions,\n  Genre,\n  SortBy,\n"
  },
  {
    "path": "src/lib/stores/provider-urls.ts",
    "chars": 514,
    "preview": "import { writable } from \"svelte/store\";\n\ninterface ProviderUrls {\n  vidsrc: string;\n  vidlink: string;\n  movies111: str"
  },
  {
    "path": "src/lib/stores/toast.ts",
    "chars": 1243,
    "preview": "import { writable } from \"svelte/store\";\n\nexport interface Toast {\n  id: string;\n  type: \"success\" | \"error\" | \"info\" | "
  },
  {
    "path": "src/lib/stores/watchlist.ts",
    "chars": 4052,
    "preview": "import { writable } from \"svelte/store\";\nimport { authStore } from \"./auth\";\nimport { get } from \"svelte/store\";\nimport "
  },
  {
    "path": "src/lib/types/auth.ts",
    "chars": 1256,
    "preview": "export interface UserSession {\n  id: number;\n  username: string;\n  email: string | null;\n  isAdmin: boolean;\n}\n\nexport i"
  },
  {
    "path": "src/lib/types/comments.ts",
    "chars": 487,
    "preview": "export interface Comment {\n  id: number;\n  content: string;\n  userId: number;\n  mediaId: number;\n  mediaType: string;\n  "
  },
  {
    "path": "src/lib/types/filters.ts",
    "chars": 3968,
    "preview": "export type SortOrder = \"asc\" | \"desc\";\nexport type SortBy = \"popularity\" | \"rating\" | \"release_date\" | \"title\";\nexport "
  },
  {
    "path": "src/lib/types/provider.ts",
    "chars": 868,
    "preview": "export interface MediaProvider {\n  id: string;\n  name: string;\n  supportsMovies: boolean;\n  supportsTVShows: boolean;\n  "
  },
  {
    "path": "src/lib/types/tmdb.ts",
    "chars": 1719,
    "preview": "export interface TMDBReleaseDatesResponse {\n  id: number;\n  results: TMDBReleaseDateResult[];\n}\n\nexport interface TMDBRe"
  },
  {
    "path": "src/lib/utils/csrf.ts",
    "chars": 1068,
    "preview": "import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from \"$lib/constants/security\";\n\nfunction getCookie(name: string): string "
  },
  {
    "path": "src/routes/+layout.server.ts",
    "chars": 148,
    "preview": "import type { ServerLoad } from \"@sveltejs/kit\";\n\nexport const load: ServerLoad = async ({ locals }) => {\n  return {\n   "
  },
  {
    "path": "src/routes/+layout.svelte",
    "chars": 2708,
    "preview": "<script lang=\"ts\">\n  import '../app.css';\n  import Navbar from '$lib/components/Navbar.svelte';\n  import { onMount } fro"
  },
  {
    "path": "src/routes/+layout.ts",
    "chars": 493,
    "preview": "import { browser } from \"$app/environment\";\nimport { watchlistStore } from \"$lib/stores/watchlist\";\nimport { authStore }"
  },
  {
    "path": "src/routes/+page.server.ts",
    "chars": 998,
    "preview": "import type { ServerLoad } from \"@sveltejs/kit\";\nimport { TMDBService, TMDBApiError } from \"$lib/services/tmdb\";\n\nexport"
  },
  {
    "path": "src/routes/+page.svelte",
    "chars": 5130,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import MediaCard from '$lib/components/MediaCard.svelte';\n  imp"
  },
  {
    "path": "src/routes/admin/moderation/+page.server.ts",
    "chars": 258,
    "preview": "import { redirect } from '@sveltejs/kit';\nimport type { PageServerLoad } from './$types';\n\nexport const load: PageServer"
  },
  {
    "path": "src/routes/admin/moderation/+page.svelte",
    "chars": 4348,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import { page } from '$app/stores';\n  import { goto } from '$ap"
  },
  {
    "path": "src/routes/api/auth/login/+server.ts",
    "chars": 1677,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { authService } from \"$l"
  },
  {
    "path": "src/routes/api/auth/logout/+server.ts",
    "chars": 585,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { clearSessionCookie, cl"
  },
  {
    "path": "src/routes/api/auth/me/+server.ts",
    "chars": 849,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { prisma } from \"$lib/se"
  },
  {
    "path": "src/routes/api/auth/register/+server.ts",
    "chars": 3237,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { authService } from \"$l"
  },
  {
    "path": "src/routes/api/auth/reset-password/request/+server.ts",
    "chars": 1509,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { authService } from \"$l"
  },
  {
    "path": "src/routes/api/auth/reset-password/reset/+server.ts",
    "chars": 1835,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { authService } from \"$l"
  },
  {
    "path": "src/routes/api/captcha/+server.ts",
    "chars": 1224,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { CaptchaService } from "
  },
  {
    "path": "src/routes/api/comments/+server.ts",
    "chars": 5073,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { getSession } from \"$li"
  },
  {
    "path": "src/routes/api/comments/[id]/+server.ts",
    "chars": 1784,
    "preview": "import { error, json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { prisma } from \""
  },
  {
    "path": "src/routes/api/comments/[id]/flag/+server.ts",
    "chars": 2830,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { prisma } from \"$lib/se"
  },
  {
    "path": "src/routes/api/comments/[id]/unflag/+server.ts",
    "chars": 1076,
    "preview": "import { error, json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { requireAdmin } "
  },
  {
    "path": "src/routes/api/comments/flagged/+server.ts",
    "chars": 847,
    "preview": "import { error, json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { CommentService "
  },
  {
    "path": "src/routes/api/comments/like/+server.ts",
    "chars": 3296,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { prisma } from \"$lib/se"
  },
  {
    "path": "src/routes/api/image/[...path]/+server.ts",
    "chars": 1900,
    "preview": "import { error } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_IMAGE_URL } from"
  },
  {
    "path": "src/routes/api/movies/+server.ts",
    "chars": 2664,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/movies/trending/+server.ts",
    "chars": 916,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/providers/+server.ts",
    "chars": 704,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport { env } from \"$env/dynamic/private\";\n\n// Validate that URLs are HTTPS and f"
  },
  {
    "path": "src/routes/api/release-info/[type]/[id]/+server.ts",
    "chars": 3947,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/tv/+server.ts",
    "chars": 2654,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/tv/[id]/season/[season]/+server.ts",
    "chars": 1462,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/tv/[id]/seasons/+server.ts",
    "chars": 960,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/tv/trending/+server.ts",
    "chars": 919,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { TMDB_API_KEY, TMDB_API"
  },
  {
    "path": "src/routes/api/users/search/+server.ts",
    "chars": 1096,
    "preview": "import { error, json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { prisma } from \""
  },
  {
    "path": "src/routes/api/watchlist/+server.ts",
    "chars": 2672,
    "preview": "import { json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { watchlistService } fro"
  },
  {
    "path": "src/routes/api/watchlist/check/+server.ts",
    "chars": 1141,
    "preview": "import { error, json } from \"@sveltejs/kit\";\nimport type { RequestEvent } from \"@sveltejs/kit\";\nimport { watchlistServic"
  },
  {
    "path": "src/routes/dmca/+page.svelte",
    "chars": 1932,
    "preview": "<div class=\"container mx-auto px-4 py-8\">\n  <div class=\"max-w-4xl mx-auto\">\n    <h1 class=\"text-4xl font-bold mb-8\">DMCA"
  },
  {
    "path": "src/routes/login/+page.svelte",
    "chars": 3174,
    "preview": "<script lang=\"ts\">\n  import { authStore } from '$lib/stores/auth';\n  import { goto } from '$app/navigation';\n\n  let iden"
  },
  {
    "path": "src/routes/media/[id]/+page.server.ts",
    "chars": 1253,
    "preview": "import { error } from \"@sveltejs/kit\";\nimport type { ServerLoad } from \"@sveltejs/kit\";\nimport { TMDBService } from \"$li"
  },
  {
    "path": "src/routes/media/[id]/+page.svelte",
    "chars": 9115,
    "preview": "<script lang=\"ts\">\n  import VideoPlayer from '$lib/components/VideoPlayer.svelte';\n  import CommentList from '$lib/compo"
  },
  {
    "path": "src/routes/movies/+page.server.ts",
    "chars": 1602,
    "preview": "import type { ServerLoad } from \"@sveltejs/kit\";\nimport { TMDBService } from \"$lib/services/tmdb\";\nimport type { FilterO"
  },
  {
    "path": "src/routes/movies/+page.svelte",
    "chars": 4701,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import MediaCard from '$lib/components/MediaCard.svelte';\n  imp"
  },
  {
    "path": "src/routes/register/+page.svelte",
    "chars": 6345,
    "preview": "<script lang=\"ts\">\n  import { authStore } from '$lib/stores/auth';\n  import { goto } from '$app/navigation';\n  import Ca"
  },
  {
    "path": "src/routes/reset-password/+page.svelte",
    "chars": 3626,
    "preview": "<script lang=\"ts\">\n  import { csrfFetch } from '$lib/utils/csrf';\n\n  let identifier = '';\n  let error = '';\n  let succes"
  },
  {
    "path": "src/routes/reset-password/[token]/+page.svelte",
    "chars": 5504,
    "preview": "<script lang=\"ts\">\n  import { goto } from '$app/navigation';\n  import { page } from '$app/stores';\n  import { csrfFetch "
  },
  {
    "path": "src/routes/search/+page.server.ts",
    "chars": 687,
    "preview": "import type { PageServerLoad } from \"./$types\";\nimport { TMDBService } from \"$lib/services/tmdb\";\n\nconst tmdb = new TMDB"
  },
  {
    "path": "src/routes/search/+page.svelte",
    "chars": 2878,
    "preview": "<script lang=\"ts\">\n  import MediaCard from '$lib/components/MediaCard.svelte';\n  import { page } from '$app/stores';\n  i"
  },
  {
    "path": "src/routes/tv/+page.server.ts",
    "chars": 1732,
    "preview": "import type { ServerLoad } from \"@sveltejs/kit\";\nimport { TMDBService } from \"$lib/services/tmdb\";\nimport type { FilterO"
  },
  {
    "path": "src/routes/tv/+page.svelte",
    "chars": 8934,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import MediaCard from '$lib/components/MediaCard.svelte';\n  imp"
  },
  {
    "path": "src/routes/watchlist/+page.server.ts",
    "chars": 1669,
    "preview": "import { error } from \"@sveltejs/kit\";\nimport type { ServerLoad } from \"@sveltejs/kit\";\nimport { watchlistService } from"
  },
  {
    "path": "src/routes/watchlist/+page.svelte",
    "chars": 1526,
    "preview": "<script lang=\"ts\">\n  import { onMount } from 'svelte';\n  import { watchlistStore } from '$lib/stores/watchlist';\n  impor"
  },
  {
    "path": "svelte.config.js",
    "chars": 586,
    "preview": "import adapter from \"@sveltejs/adapter-node\";\nimport { preprocessMeltUI } from \"@melt-ui/pp\";\nimport sequence from \"svel"
  },
  {
    "path": "tailwind.config.js",
    "chars": 772,
    "preview": "/** @type {import('tailwindcss').Config} */\nexport default {\n  content: [\"./src/**/*.{html,js,svelte,ts}\"],\n  theme: {\n "
  },
  {
    "path": "tsconfig.json",
    "chars": 567,
    "preview": "{\n  \"extends\": \"./.svelte-kit/tsconfig.json\",\n  \"compilerOptions\": {\n    \"allowJs\": true,\n    \"checkJs\": true,\n    \"esMo"
  },
  {
    "path": "vite.config.ts",
    "chars": 221,
    "preview": "import { sveltekit } from \"@sveltejs/kit/vite\";\nimport { defineConfig } from \"vite\";\n\nexport default defineConfig({\n  pl"
  }
]

About this extraction

This page contains the full source code of the gmonarque/streamium GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 127 files (305.1 KB), approximately 84.6k tokens, and a symbol index with 302 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!