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.
## 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
================================================
///
// 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
================================================
%sveltekit.head%
%sveltekit.body%
================================================
FILE: src/env.d.ts
================================================
///
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();
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(/
{#if verified}
{/if}
================================================
FILE: src/lib/components/CommentForm.svelte
================================================
Add a Comment
{#if $authStore.isAuthenticated}
{:else}
Please log in to leave a comment.
{/if}
================================================
FILE: src/lib/components/CommentList.svelte
================================================
Sort by:
•
{#if isLoading}
{:else if error}
{:else if comments.length === 0}
No comments yet
Be the first to share your thoughts!
{:else}
{#each sortedComments as comment (comment.id)}
{#if !comment.parentId}
{comment.user.username[0].toUpperCase()}
{comment.user.username}
{formatDate(comment.createdAt)}
{@html sanitizeContent(comment.content)}
{#if $authStore.isAuthenticated}
{/if}
{#if $authStore.isAuthenticated && !comment.flagged}
{/if}
{#if replyingToId === comment.id}
{/if}
{#if comment.replies.length > 0}
{#each comment.replies as reply (reply.id)}
{reply.user.username[0].toUpperCase()}
{reply.user.username}
{formatDate(reply.createdAt)}
{@html sanitizeContent(reply.content)}
{#if $authStore.isAuthenticated && !reply.flagged}
{/if}
{/each}
{/if}
{/if}
{/each}
{/if}
================================================
FILE: src/lib/components/CommentModeration.svelte
================================================
{#if isAdmin}
{#if comment.flagged}
{:else}
{/if}
{/if}
================================================
FILE: src/lib/components/EmojiPicker.svelte
================================================
================================================
FILE: src/lib/components/EpisodeSelector.svelte
================================================
{#if showModal}
{#each seasons as season}
{/each}
{#if selectedSeason && episodes.length > 0}
{#each episodes as episode}
{/each}
{:else}
Select a season to view episodes
{/if}
{/if}
================================================
FILE: src/lib/components/Hero.svelte
================================================
{#if backdropUrl}
{/if}
{title}
{#if media?.overview}
{media.overview}
{/if}
Watch Now
{media?.vote_average?.toFixed(1) || '0.0'}
/ 10
================================================
FILE: src/lib/components/Image.svelte
================================================

{#if !loaded}
{/if}
================================================
FILE: src/lib/components/MediaCard.svelte
================================================
================================================
FILE: src/lib/components/MediaFilters.svelte
================================================
================================================
FILE: src/lib/components/MediaPlayer.svelte
================================================
================================================
FILE: src/lib/components/MentionList.svelte
================================================
{#each items as item, index}
{/each}
================================================
FILE: src/lib/components/Navbar.svelte
================================================
================================================
FILE: src/lib/components/NextEpisode.svelte
================================================
{#if !loading && nextEpisode}
{/if}
================================================
FILE: src/lib/components/Pagination.svelte
================================================
{#if totalPages > 1}
Page {currentPage} of {totalPages}
{/if}
================================================
FILE: src/lib/components/ReplyForm.svelte
================================================
================================================
FILE: src/lib/components/RichTextEditor.svelte
================================================
================================================
FILE: src/lib/components/Toast.svelte
================================================
{#each toasts as toast (toast.id)}
{@html icons[toast.type]}
{toast.message}
{/each}
================================================
FILE: src/lib/components/VideoPlayer.svelte
================================================
{#if loading}
{/if}
{#if error}
{error}
{/if}
================================================
FILE: src/lib/components/WatchlistButton.svelte
================================================
================================================
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;
interface MentionNode {
attrs: {
id?: string | null;
label?: string | null;
};
}
export interface MentionOptions {
HTMLAttributes?: HTMLAttrs;
renderLabel?: (props: { options: MentionOptions; node: MentionNode }) => string;
suggestion?: Partial;
}
interface CommandProps {
id: number;
label: string;
}
export const Mention = Extension.create({
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 {
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 {
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 {
const salt = await bcrypt.genSalt(10);
return bcrypt.hash(password, salt);
}
async comparePasswords(password: string, hash: string): Promise {
return bcrypt.compare(password, hash);
}
async generateToken(user: UserSession): Promise {
// 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 {
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 {
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 {
// 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 {
// 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 {
const hashedPassword = await this.hashPassword(newPassword);
await prisma.user.update({
where: { id: userId },
data: { passwordHash: hashedPassword },
});
}
async createResetToken(identifier: string): Promise {
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 {
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 {
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();
// 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 {
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 {
await prisma.commentLike.create({
data: {
userId,
commentId,
},
});
}
async unlikeComment(userId: number, commentId: number): Promise {
await prisma.commentLike.delete({
where: {
userId_commentId: {
userId,
commentId,
},
},
});
}
async updateComment(
commentId: number,
userId: number,
content: string,
): Promise {
return prisma.comment.update({
where: {
id: commentId,
userId,
},
data: {
content,
},
});
}
async deleteComment(commentId: number, userId: number): Promise {
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 {
return prisma.comment.update({
where: {
id: commentId,
},
data: {
flagged: true,
flagReason: reason || "No reason provided",
flaggedAt: new Date(),
},
});
}
async unflagComment(commentId: number): Promise {
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();
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();
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(response: Response): Promise {
if (!response.ok) {
throw new ApiError(
response.status,
`API request failed: ${response.statusText}`,
);
}
const data = await response.json();
return data as T;
}
async get(
endpoint: string,
params: Record = {},
): Promise {
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(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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
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 {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - CACHE_CLEANUP_DAYS);
const oldEntries = await prisma.$queryRaw`
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 {
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`
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 {
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 {
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();
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;
}
const cache = new Map();
export async function getReleaseType(
mediaId: number,
mediaType: string,
): Promise {
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;
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(endpoint: string, params: QueryParams = {}, retryCount = 0): Promise {
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(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 {
return this.fetch(`/movie/${id}`, {
append_to_response: 'videos'
});
}
async getTVShowDetails(id: number): Promise {
return this.fetch(`/tv/${id}`, {
append_to_response: 'videos'
});
}
async getMovieGenres(): Promise {
const response = await this.fetch<{ genres: TMDBGenre[] }>('/genre/movie/list');
return response.genres;
}
async getTVGenres(): Promise {
const response = await this.fetch<{ genres: TMDBGenre[] }>('/genre/tv/list');
return response.genres;
}
async searchMovies(query: string, page = 1): Promise> {
return this.fetch>('/search/movie', {
query,
page,
include_adult: false,
language: 'en-US'
});
}
async searchTVShows(query: string, page = 1): Promise> {
return this.fetch>('/search/tv', {
query,
page,
include_adult: false,
language: 'en-US'
});
}
async searchMulti(query: string, page = 1): Promise> {
return this.fetch>('/search/multi', {
query,
page,
include_adult: false,
language: 'en-US'
});
}
async discoverMovies(params: QueryParams = {}): Promise> {
return this.fetch>('/discover/movie', {
include_adult: false,
language: 'en-US',
...params
});
}
async discoverTVShows(params: QueryParams = {}): Promise> {
return this.fetch>('/discover/tv', {
include_adult: false,
language: 'en-US',
...params
});
}
async getTrending(mediaType: 'movie' | 'tv', timeWindow: 'day' | 'week' = 'week'): Promise> {
return this.fetch>(`/trending/${mediaType}/${timeWindow}`);
}
async getTrendingMovies(timeWindow: 'day' | 'week' = 'week'): Promise> {
return this.fetch>(`/trending/movie/${timeWindow}`);
}
async getTrendingTVShows(timeWindow: 'day' | 'week' = 'week'): Promise> {
return this.fetch>(`/trending/tv/${timeWindow}`);
}
async getPopularMovies(page = 1): Promise> {
return this.fetch>('/movie/popular', { page });
}
async getPopularTVShows(page = 1): Promise> {
return this.fetch>('/tv/popular', { page });
}
async getTopRatedMovies(page = 1): Promise> {
return this.fetch>('/movie/top_rated', { page });
}
async getTopRatedTVShows(page = 1): Promise> {
return this.fetch>('/tv/top_rated', { page });
}
async getUpcomingMovies(page = 1): Promise> {
return this.fetch>('/movie/upcoming', { page });
}
async getOnTheAirTVShows(page = 1): Promise> {
return this.fetch>('/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 {
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 {
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 {
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 {
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({
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({
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(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, 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(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([]);
function addToast(toast: Omit) {
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({
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;
comparePasswords(password: string, hash: string): Promise;
generateToken(user: UserSession): Promise;
verifyToken(token: string): Promise;
createUser(
username: string,
email: string | null,
password: string,
): Promise;
validateUser(
usernameOrEmail: string,
password: string,
): Promise;
findUserByIdentifier(identifier: string): Promise;
updatePassword(userId: number, newPassword: string): Promise;
createResetToken(identifier: string): Promise;
validateResetToken(token: string): Promise;
clearResetToken(userId: number): Promise;
}
export async function verifyToken(token: string): Promise {
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) {
filters.year = parseInt(year);
}
const rating = params.get("rating");
if (rating) {
filters.rating = parseInt(rating);
}
const sortBy = params.get("sortBy") as SortBy;
if (sortBy) {
filters.sortBy = sortBy;
}
const sortOrder = params.get("sortOrder") as SortOrder;
if (sortOrder) {
filters.sortOrder = sortOrder;
}
const watchStatus = params.get("watchStatus") as WatchStatus;
if (watchStatus) {
filters.watchStatus = watchStatus;
}
const page = params.get("page");
if (page) {
filters.page = parseInt(page);
}
const limit = params.get("limit");
if (limit) {
filters.limit = parseInt(limit);
}
return filters;
}
================================================
FILE: src/lib/types/provider.ts
================================================
export interface MediaProvider {
id: string;
name: string;
supportsMovies: boolean;
supportsTVShows: boolean;
requiresLanguage?: boolean;
languages?: string[];
getMovieUrl: (
mediaId: string | number,
options?: ProviderOptions,
) => Promise;
getTVShowUrl?: (
mediaId: string | number,
seasonId: number,
episodeId: number,
options?: ProviderOptions,
) => Promise;
}
export interface ProviderOptions {
language?: string;
primaryColor?: string;
secondaryColor?: string;
iconColor?: string;
autoPlay?: boolean;
autoNext?: boolean;
}
export interface StreamingQuality {
quality: string;
url: string;
metadata?: {
baseUrl?: string;
[key: string]: unknown;
};
}
export interface ProviderResponse {
url: string;
type: "iframe" | "hls" | "dash";
qualities?: StreamingQuality[];
}
================================================
FILE: src/lib/types/tmdb.ts
================================================
export interface TMDBReleaseDatesResponse {
id: number;
results: TMDBReleaseDateResult[];
}
export interface TMDBReleaseDateResult {
iso_3166_1: string;
release_dates: TMDBReleaseDate[];
}
export interface TMDBReleaseDate {
certification: string;
iso_639_1?: string;
release_date: string;
type: number;
note?: string;
}
export interface TMDBMediaResponse {
id: number;
title?: string;
name?: string;
overview: string;
poster_path: string | null;
backdrop_path: string | null;
release_date?: string;
first_air_date?: string;
vote_average: number;
vote_count: number;
genre_ids: number[];
media_type?: "movie" | "tv";
popularity: number;
}
export interface TMDBResponse {
page: number;
results: T[];
total_pages: number;
total_results: number;
}
export interface TMDBVideoResponse {
id: number;
results: TMDBVideo[];
}
export interface TMDBVideo {
id: string;
key: string;
name: string;
site: string;
size: number;
type: string;
}
export interface TMDBMovie extends TMDBMediaResponse {
title: string;
release_date: string;
media_type: "movie";
}
export interface TMDBTVShow extends TMDBMediaResponse {
name: string;
first_air_date: string;
media_type: "tv";
}
export interface TMDBGenre {
id: number;
name: string;
}
export interface TMDBWatchProvider {
logo_path: string;
provider_id: number;
provider_name: string;
display_priority: number;
}
export interface TMDBWatchProviderRegion {
link?: string;
flatrate?: TMDBWatchProvider[];
rent?: TMDBWatchProvider[];
buy?: TMDBWatchProvider[];
}
export interface TMDBWatchProvidersResponse {
id: number;
results: Record;
}
================================================
FILE: src/lib/utils/csrf.ts
================================================
import { CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from "$lib/constants/security";
function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = document.cookie.match(new RegExp(`(?:^|; )${escapedName}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
export function getCsrfToken(): string | null {
return getCookie(CSRF_COOKIE_NAME);
}
export function withCsrfHeaders(init: RequestInit = {}): RequestInit {
const token = getCsrfToken();
if (!token) return init;
const headers = new Headers(init.headers ?? {});
headers.set(CSRF_HEADER_NAME, token);
return {
...init,
headers,
};
}
export async function csrfFetch(
input: RequestInfo | URL,
init: RequestInit = {},
): Promise {
const method = (init.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD" || method === "OPTIONS") {
return fetch(input, init);
}
return fetch(input, withCsrfHeaders(init));
}
================================================
FILE: src/routes/+layout.server.ts
================================================
import type { ServerLoad } from "@sveltejs/kit";
export const load: ServerLoad = async ({ locals }) => {
return {
user: locals.user,
};
};
================================================
FILE: src/routes/+layout.svelte
================================================
================================================
FILE: src/routes/+layout.ts
================================================
import { browser } from "$app/environment";
import { watchlistStore } from "$lib/stores/watchlist";
import { authStore } from "$lib/stores/auth";
import { get } from "svelte/store";
export const load = async () => {
if (browser) {
const auth = get(authStore);
if (auth.user) {
try {
await watchlistStore.getWatchlist();
} catch (error) {
console.error("Failed to load watchlist:", error);
}
} else {
watchlistStore.reset();
}
}
};
================================================
FILE: src/routes/+page.server.ts
================================================
import type { ServerLoad } from "@sveltejs/kit";
import { TMDBService, TMDBApiError } from "$lib/services/tmdb";
export const load = (async () => {
const tmdb = new TMDBService();
if (!tmdb.isConfigured()) {
return {
trendingMovies: [],
trendingTVShows: [],
error: 'TMDB API key is not configured. Please add TMDB_API_KEY to your .env file.',
};
}
try {
const [trendingMovies, trendingTVShows] = await Promise.all([
tmdb.getTrendingMovies(),
tmdb.getTrendingTVShows(),
]);
return {
trendingMovies: trendingMovies.results.slice(0, 12),
trendingTVShows: trendingTVShows.results.slice(0, 12),
error: null,
};
} catch (err) {
const error = err instanceof TMDBApiError
? err.message
: 'Failed to load content. Please try again later.';
console.error('Homepage load error:', err);
return {
trendingMovies: [],
trendingTVShows: [],
error,
};
}
}) satisfies ServerLoad;
================================================
FILE: src/routes/+page.svelte
================================================
{#if featuredMedia}
{/if}
{#if loading}
{:else if error}
{error}
Get a free API key at themoviedb.org
{:else if trendingMovies.length === 0}
No trending movies available
{:else}
{#each trendingMovies as movie (movie.id)}
{/each}
{/if}
{#if loading}
{:else if error}
{:else if trendingShows.length === 0}
No trending TV shows available
{:else}
{#each trendingShows as show (show.id)}
{/each}
{/if}
================================================
FILE: src/routes/admin/moderation/+page.server.ts
================================================
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.isAdmin) {
throw redirect(303, '/');
}
return {
user: locals.user
};
};
================================================
FILE: src/routes/admin/moderation/+page.svelte
================================================
Comment Moderation
{#if loading}
{:else if error}
{error}
{:else if comments.length === 0}
No flagged comments found
{:else}
{#each comments as comment (comment.id)}
Posted by {comment.user.username} on {formatDate(comment.createdAt)}
Flagged on {formatDate(comment.flaggedAt)}
{#if comment.flagReason}
- Reason: {comment.flagReason}
{/if}
{comment.content}
Media: {comment.mediaType} #{comment.mediaId}
Likes: {comment._count?.likes || 0}
Replies: {comment._count?.replies || 0}
{/each}
{/if}
================================================
FILE: src/routes/api/auth/login/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { authService } from "$lib/server/services/auth";
import { createSessionCookie, createCsrfCookie, createCsrfToken } from "$lib/server/auth";
import { RateLimitService } from "$lib/server/services/rate-limit";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { dev } from "$app/environment";
export async function POST({ request, getClientAddress }: RequestEvent) {
const clientIp = getClientAddress();
const rateLimit = RateLimitService.checkLoginLimit(clientIp);
if (!rateLimit.allowed) {
return json(
{ error: `Too many login attempts. Please try again in ${Math.ceil(rateLimit.timeLeft! / 60)} minutes.` },
{ status: 429 }
);
}
try {
const { usernameOrEmail, identifier, password } = await request.json();
const loginIdentifier = usernameOrEmail ?? identifier;
if (!loginIdentifier || !password) {
return json({ error: "Username/Email and password are required" }, { status: 400 });
}
const user = await authService.validateUser(loginIdentifier, password);
if (!user) {
return json({ error: "Invalid credentials" }, { status: 401 });
}
const token = await authService.generateToken(user);
const isProduction = !dev;
const csrfToken = createCsrfToken();
const headers = new Headers();
headers.append("Set-Cookie", createSessionCookie(token, isProduction));
headers.append("Set-Cookie", createCsrfCookie(csrfToken, isProduction));
return json(user, {
headers,
});
} catch (error) {
return handleDatabaseError(error, "login");
}
}
================================================
FILE: src/routes/api/auth/logout/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { clearSessionCookie, clearCsrfCookie } from "$lib/server/auth";
export async function POST(_event: RequestEvent) {
try {
const headers = new Headers();
headers.append("Set-Cookie", clearSessionCookie());
headers.append("Set-Cookie", clearCsrfCookie());
return json(
{ success: true },
{
headers,
},
);
} catch (error) {
console.error("Logout error:", error);
return new Response("Internal server error", { status: 500 });
}
}
================================================
FILE: src/routes/api/auth/me/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { prisma } from "$lib/server/prisma";
import { getSession } from "$lib/server/auth";
export async function GET({ cookies }: RequestEvent) {
try {
const session = await getSession(cookies);
if (!session?.userId) {
return new Response(null, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.userId },
select: {
id: true,
username: true,
email: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
return new Response(null, { status: 401 });
}
return json(user);
} catch (error) {
console.error("Error fetching user:", error);
return new Response("Internal server error", { status: 500 });
}
}
================================================
FILE: src/routes/api/auth/register/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { authService } from "$lib/server/services/auth";
import { createSessionCookie, createCsrfCookie, createCsrfToken } from "$lib/server/auth";
import { RateLimitService } from "$lib/server/services/rate-limit";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { dev } from "$app/environment";
import { CaptchaService } from "$lib/server/services/captcha";
const PASSWORD_MIN_LENGTH = 8;
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
export async function POST({ request, getClientAddress }: RequestEvent) {
const clientIp = getClientAddress();
const rateLimit = RateLimitService.checkRegisterLimit(clientIp);
if (!rateLimit.allowed) {
return json(
{ error: `Too many registration attempts. Please try again in ${Math.ceil(rateLimit.timeLeft! / 60)} minutes.` },
{ status: 429 }
);
}
try {
const { username, email, password, captchaId, captchaAnswer } = await request.json();
if (!username || !password) {
return json({ error: "Username and password are required" }, { status: 400 });
}
if (username.length < 3 || username.length > 15) {
return json({ error: "Username must be between 3 and 15 characters" }, { status: 400 });
}
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
return json({ error: "Username can only contain letters, numbers, and underscores" }, { status: 400 });
}
if (password.length < PASSWORD_MIN_LENGTH) {
return json({ error: `Password must be at least ${PASSWORD_MIN_LENGTH} characters` }, { status: 400 });
}
if (!PASSWORD_REGEX.test(password)) {
return json({ error: "Password must contain at least one uppercase letter, one lowercase letter, and one number" }, { status: 400 });
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
return json({ error: "Invalid email format" }, { status: 400 });
}
if (!captchaId || !captchaAnswer) {
return json({ error: "Captcha verification is required" }, { status: 400 });
}
const captchaValid = CaptchaService.validateCaptcha(captchaId, captchaAnswer, { consume: true });
if (!captchaValid) {
return json({ error: "Invalid captcha. Please try again." }, { status: 400 });
}
const existingUser = await authService.findUserByIdentifier(email || username);
if (existingUser) {
if (email && existingUser.email === email) {
return json({ error: "Email already registered" }, { status: 400 });
}
if (existingUser.username === username) {
return json({ error: "Username already taken" }, { status: 400 });
}
}
const user = await authService.createUser(username, email, password);
const token = await authService.generateToken(user);
const isProduction = !dev;
const csrfToken = createCsrfToken();
const headers = new Headers();
headers.append("Set-Cookie", createSessionCookie(token, isProduction));
headers.append("Set-Cookie", createCsrfCookie(csrfToken, isProduction));
return json(user, {
headers,
});
} catch (error) {
return handleDatabaseError(error, "register");
}
}
================================================
FILE: src/routes/api/auth/reset-password/request/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { authService } from "$lib/server/services/auth";
import { RateLimitService } from "$lib/server/services/rate-limit";
export async function POST({ request }: RequestEvent) {
const { identifier } = await request.json();
if (!identifier) {
return json({ error: "Username or email is required" }, { status: 400 });
}
const rateLimit = RateLimitService.checkPasswordResetLimit(identifier);
if (!rateLimit.allowed) {
return json({
error: `Too many reset attempts. Please try again in ${Math.ceil(rateLimit.timeLeft! / 60)} minutes.`
}, { status: 429 });
}
try {
const resetToken = await authService.createResetToken(identifier);
if (!resetToken) {
return json({
message: "If an account exists with this username/email, password reset instructions will be sent"
});
}
const user = await authService.findUserByIdentifier(identifier);
if (!user?.email) {
return json({
message: "If an account exists with this username/email, password reset instructions will be sent"
});
}
return json({
message: "If an account exists with this username/email, password reset instructions will be sent"
});
} catch (error) {
console.error("Password reset request error:", error);
return json(
{
error: "An error occurred while processing your request"
},
{ status: 500 },
);
}
}
================================================
FILE: src/routes/api/auth/reset-password/reset/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { authService } from "$lib/server/services/auth";
import { RateLimitService } from "$lib/server/services/rate-limit";
const PASSWORD_MIN_LENGTH = 8;
const PASSWORD_REGEX = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/;
export async function POST({ request }: RequestEvent) {
const { token, newPassword } = await request.json();
if (!token || !newPassword) {
return json(
{ error: "Token and new password are required" },
{ status: 400 },
);
}
if (newPassword.length < PASSWORD_MIN_LENGTH) {
return json(
{ error: `Password must be at least ${PASSWORD_MIN_LENGTH} characters long` },
{ status: 400 },
);
}
if (!PASSWORD_REGEX.test(newPassword)) {
return json(
{ error: "Password must contain at least one uppercase letter, one lowercase letter, and one number" },
{ status: 400 },
);
}
const rateLimit = RateLimitService.checkPasswordResetLimit(token);
if (!rateLimit.allowed) {
return json({
error: `Too many attempts. Please try again in ${Math.ceil(rateLimit.timeLeft! / 60)} minutes.`
}, { status: 429 });
}
try {
const userId = await authService.validateResetToken(token);
if (!userId) {
return json(
{ error: "Invalid or expired reset link. Please request a new one." },
{ status: 400 }
);
}
await authService.updatePassword(userId, newPassword);
await authService.clearResetToken(userId);
return json({ message: "Your password has been successfully reset" });
} catch (error) {
console.error("Password reset error:", error);
return json(
{
error: "An error occurred while resetting your password. Please try again."
},
{ status: 500 },
);
}
}
================================================
FILE: src/routes/api/captcha/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { CaptchaService } from "$lib/server/services/captcha";
import { z } from "zod";
const validateSchema = z.object({
id: z.string().length(32),
answer: z.string().min(1).max(10),
});
// Generate a new captcha
export async function GET({ getClientAddress }: RequestEvent) {
const { id, text } = CaptchaService.generateCaptcha();
// Return captcha ID and text for client-side rendering
// The text is only used for rendering the canvas image client-side
// Validation still happens server-side
return json({ id, text });
}
// Validate captcha (used during form submission)
export async function POST({ request }: RequestEvent) {
try {
const body = await request.json();
const validation = validateSchema.safeParse(body);
if (!validation.success) {
return json({ valid: false, error: "Invalid request" }, { status: 400 });
}
const { id, answer } = validation.data;
const isValid = CaptchaService.validateCaptcha(id, answer, { consume: false });
return json({ valid: isValid });
} catch {
return json({ valid: false, error: "Validation failed" }, { status: 500 });
}
}
================================================
FILE: src/routes/api/comments/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { getSession } from "$lib/server/auth";
import { validateComment } from "$lib/shared/comment-validation";
import { commentService } from "$lib/server/services/comments";
import { commentRateLimit } from "$lib/server/services/rate-limit";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { z } from 'zod';
const mediaTypeSchema = z.enum(['movie', 'tv']);
const commentSchema = z.object({
mediaId: z.number().int().positive(),
mediaType: mediaTypeSchema,
content: z.string().min(1).max(1000),
parentId: z.number().int().positive().nullable().optional(),
season: z.number().int().min(1).max(100).optional(),
episode: z.number().int().min(1).max(2000).optional(),
});
function validateNumericInput(value: string | null, min: number, max: number): number | null {
if (!value) return null;
const num = parseInt(value);
if (isNaN(num) || num < min || num > max) return null;
return num;
}
function checkQueryComplexity(params: URLSearchParams): boolean {
const complexityScore =
(params.has('mediaId') ? 1 : 0) +
(params.has('mediaType') ? 1 : 0) +
(params.has('season') ? 2 : 0) +
(params.has('episode') ? 2 : 0);
return complexityScore <= 6;
}
export async function GET({ url, cookies, getClientAddress }: RequestEvent) {
try {
const clientIp = getClientAddress();
if (!checkRateLimit(clientIp)) {
return json({ error: "Rate limit exceeded" }, { status: 429 });
}
if (!checkQueryComplexity(url.searchParams)) {
return json({ error: "Query too complex" }, { status: 400 });
}
const rawMediaId = url.searchParams.get("mediaId");
const rawMediaType = url.searchParams.get("mediaType");
const rawPage = url.searchParams.get("page") || "1";
const rawLimit = url.searchParams.get("limit") || "10";
const rawParentId = url.searchParams.get("parentId");
const mediaId = validateNumericInput(rawMediaId, 1, Number.MAX_SAFE_INTEGER);
if (!mediaId) {
return json({ error: "Invalid Media ID" }, { status: 400 });
}
const mediaTypeResult = mediaTypeSchema.safeParse(rawMediaType);
if (!mediaTypeResult.success) {
return json({ error: "Invalid media type" }, { status: 400 });
}
const mediaType = mediaTypeResult.data;
const page = validateNumericInput(rawPage, 1, 1000) || 1;
const limit = validateNumericInput(rawLimit, 1, 100) || 10;
const parentId = validateNumericInput(rawParentId, 1, Number.MAX_SAFE_INTEGER);
const session = await getSession(cookies);
const userId = session?.userId;
const { comments, total } = await commentService.getComments(
mediaId,
mediaType,
userId,
parentId,
page,
limit
);
return json({
comments,
total,
page,
totalPages: Math.ceil(total / limit),
});
} catch (error) {
return handleDatabaseError(error, "fetch comments");
}
}
export async function POST({ request, cookies, getClientAddress }: RequestEvent) {
try {
const clientIp = getClientAddress();
if (!checkRateLimit(clientIp)) {
return json({ error: "Rate limit exceeded" }, { status: 429 });
}
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const body = await request.json();
const validationResult = commentSchema.safeParse(body);
if (!validationResult.success) {
return json({ error: "Invalid request data", details: validationResult.error }, { status: 400 });
}
const { content, mediaId, mediaType, parentId } = validationResult.data;
const contentValidation = validateComment(content);
if (!contentValidation.isValid) {
return json({ error: contentValidation.error || "Invalid comment content" }, { status: 400 });
}
const comment = await commentService.createComment({
userId: session.userId,
mediaId,
mediaType,
content,
parentId,
});
return json(comment);
} catch (error) {
return handleDatabaseError(error, "create comment");
}
}
export async function DELETE({ url, cookies, getClientAddress }: RequestEvent) {
try {
const clientIp = getClientAddress();
if (!checkRateLimit(clientIp)) {
return json({ error: "Rate limit exceeded" }, { status: 429 });
}
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const commentId = validateNumericInput(url.searchParams.get("id"), 1, Number.MAX_SAFE_INTEGER);
if (!commentId) {
return json({ error: "Invalid comment ID" }, { status: 400 });
}
await commentService.deleteComment(commentId, session.userId);
return json({ success: true });
} catch (error) {
return handleDatabaseError(error, "delete comment");
}
}
function checkRateLimit(ip: string): boolean {
return commentRateLimit.checkRateLimit(ip);
}
================================================
FILE: src/routes/api/comments/[id]/+server.ts
================================================
import { error, json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { prisma } from "$lib/server/prisma";
import { isDatabaseConnectionError } from "$lib/server/services/db-error";
export async function DELETE(event: RequestEvent) {
const user = event.locals.user;
if (!user) {
throw error(401, "Unauthorized");
}
const id = event.params.id;
if (!id) {
throw error(400, "Comment ID is required");
}
const commentId = parseInt(id);
if (isNaN(commentId) || commentId <= 0) {
throw error(400, "Invalid comment ID");
}
try {
const comment = await prisma.comment.findUnique({
where: { id: commentId }
});
if (!comment) {
throw error(404, "Comment not found");
}
// Clear authorization: user must own the comment OR be an admin
const isOwner = comment.userId === user.id;
if (!isOwner && !user.isAdmin) {
throw error(403, "You can only delete your own comments");
}
const updatedComment = await prisma.comment.update({
where: { id: commentId },
data: {
content: user.isAdmin && !isOwner ? "[Comment removed by moderator]" : "[Comment deleted]",
flagged: false,
flagReason: null,
flaggedAt: null
}
});
return json({ success: true, comment: updatedComment });
} catch (err) {
if (err && typeof err === 'object' && 'status' in err) {
throw err; // Re-throw SvelteKit errors
}
if (isDatabaseConnectionError(err)) {
console.error("Database unavailable:", err instanceof Error ? err.message : 'Unknown error');
throw error(503, "Service temporarily unavailable");
}
console.error("Error deleting comment:", err);
throw error(500, "Failed to delete comment");
}
}
================================================
FILE: src/routes/api/comments/[id]/flag/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { prisma } from "$lib/server/prisma";
import { getSession } from "$lib/server/auth";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { z } from "zod";
const flagSchema = z.object({
reason: z.string().max(500).optional(),
});
export async function POST({ params, request, cookies }: RequestEvent) {
try {
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const id = params.id;
if (!id) {
return json({ error: "Comment ID is required" }, { status: 400 });
}
const commentId = parseInt(id);
if (isNaN(commentId) || commentId <= 0) {
return json({ error: "Invalid comment ID" }, { status: 400 });
}
const body = await request.json();
const validation = flagSchema.safeParse(body);
if (!validation.success) {
return json({ error: "Invalid flag reason" }, { status: 400 });
}
const { reason } = validation.data;
const existingComment = await prisma.comment.findUnique({
where: { id: commentId }
});
if (!existingComment) {
return json({ error: "Comment not found" }, { status: 404 });
}
if (existingComment.flagged) {
return json({ error: "Comment is already flagged" }, { status: 400 });
}
const updatedComment = await prisma.comment.update({
where: { id: commentId },
data: {
flagged: true,
flagReason: reason || "No reason provided",
flaggedAt: new Date()
}
});
return json({
success: true,
comment: updatedComment
});
} catch (error) {
return handleDatabaseError(error, "flag comment");
}
}
export async function DELETE({ params, cookies }: RequestEvent) {
try {
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.userId }
});
if (!user?.isAdmin) {
return json({ error: "Unauthorized" }, { status: 403 });
}
const id = params.id;
if (!id) {
return json({ error: "Comment ID is required" }, { status: 400 });
}
const commentId = parseInt(id);
if (isNaN(commentId)) {
return json({ error: "Invalid comment ID" }, { status: 400 });
}
const updatedComment = await prisma.comment.update({
where: { id: commentId },
data: {
flagged: false,
flagReason: null,
flaggedAt: null
}
});
return json({
success: true,
comment: updatedComment
});
} catch (error) {
return handleDatabaseError(error, "unflag comment");
}
}
================================================
FILE: src/routes/api/comments/[id]/unflag/+server.ts
================================================
import { error, json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { requireAdmin } from "$lib/server/admin-middleware";
import { prisma } from "$lib/server/prisma";
export async function POST(event: RequestEvent) {
await requireAdmin(event);
const id = event.params.id;
if (!id) {
throw error(400, "Comment ID is required");
}
const commentId = parseInt(id);
if (isNaN(commentId)) {
throw error(400, "Invalid comment ID");
}
try {
const existingComment = await prisma.comment.findUnique({
where: { id: commentId }
});
if (!existingComment) {
throw error(404, "Comment not found");
}
const updatedComment = await prisma.comment.update({
where: { id: commentId },
data: {
flagged: false,
flagReason: null,
flaggedAt: null
}
});
return json({
success: true,
comment: updatedComment
});
} catch (err) {
console.error("Error unflagging comment:", err);
throw error(500, "Failed to unflag comment");
}
}
================================================
FILE: src/routes/api/comments/flagged/+server.ts
================================================
import { error, json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { CommentService } from "$lib/services/comments";
import { requireAdmin } from "$lib/server/admin-middleware";
const commentService = new CommentService();
export async function GET(event: RequestEvent) {
await requireAdmin(event);
const page = parseInt(event.url.searchParams.get("page") || "1");
const limit = parseInt(event.url.searchParams.get("limit") || "10");
try {
const { comments, total } = await commentService.getFlaggedComments(
page,
limit,
);
return json({
comments,
total,
page,
totalPages: Math.ceil(total / limit)
});
} catch (err) {
console.error("Error fetching flagged comments:", err);
throw error(500, "Failed to fetch flagged comments");
}
}
================================================
FILE: src/routes/api/comments/like/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { prisma } from "$lib/server/prisma";
import { getSession } from "$lib/server/auth";
import { RateLimitService } from "$lib/server/services/rate-limit";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { z } from "zod";
const likeSchema = z.object({
commentId: z.number().int().positive(),
});
export async function POST({ request, cookies }: RequestEvent) {
try {
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
// Rate limit likes to prevent spam
const rateLimit = RateLimitService.checkLikeLimit(session.userId);
if (!rateLimit.allowed) {
return json(
{ error: `Too many likes. Please try again in ${rateLimit.timeLeft} seconds.` },
{ status: 429 }
);
}
const body = await request.json();
const validation = likeSchema.safeParse(body);
if (!validation.success) {
return json({ error: "Invalid comment ID" }, { status: 400 });
}
const { commentId } = validation.data;
const comment = await prisma.comment.findUnique({
where: { id: commentId },
});
if (!comment) {
return json({ error: "Comment not found" }, { status: 404 });
}
const existingLike = await prisma.commentLike.findUnique({
where: {
userId_commentId: {
userId: session.userId,
commentId,
},
},
});
if (existingLike) {
await prisma.commentLike.delete({
where: {
userId_commentId: {
userId: session.userId,
commentId,
},
},
});
} else {
await prisma.commentLike.create({
data: {
userId: session.userId,
commentId,
},
});
}
const updatedComment = await prisma.comment.findUnique({
where: { id: commentId },
include: {
_count: {
select: {
likes: true,
},
},
},
});
return json({
isLiked: !existingLike,
likeCount: updatedComment?._count.likes || 0,
});
} catch (err) {
return handleDatabaseError(err, "toggle like");
}
}
export async function GET({ url, cookies }: RequestEvent) {
try {
const session = await getSession(cookies);
if (!session?.userId) {
return json({ error: "Unauthorized" }, { status: 401 });
}
const rawCommentId = url.searchParams.get("commentId");
if (!rawCommentId) {
return json({ error: "Comment ID is required" }, { status: 400 });
}
const commentId = parseInt(rawCommentId, 10);
if (isNaN(commentId) || commentId <= 0) {
return json({ error: "Invalid comment ID" }, { status: 400 });
}
const like = await prisma.commentLike.findUnique({
where: {
userId_commentId: {
userId: session.userId,
commentId,
},
},
});
const likeCount = await prisma.commentLike.count({
where: {
commentId,
},
});
return json({
isLiked: !!like,
likeCount,
});
} catch (err) {
return handleDatabaseError(err, "check like status");
}
}
================================================
FILE: src/routes/api/image/[...path]/+server.ts
================================================
import { error } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_IMAGE_URL } from "$env/static/private";
// Allowed image sizes from TMDB
const ALLOWED_SIZES = new Set([
'w45', 'w92', 'w154', 'w185', 'w300', 'w342', 'w500', 'w780',
'w1280', 'h632', 'original'
]);
// Image path pattern: alphanumeric, underscore, hyphen, and dots only
const PATH_PATTERN = /^[a-zA-Z0-9_\-./]+$/;
export async function GET({ params, fetch }: RequestEvent) {
if (!TMDB_IMAGE_URL) {
throw error(500, "TMDB image URL not configured");
}
const path = params.path;
if (!path) {
throw error(400, "Image path is required");
}
const [size, ...imagePath] = path.split("/");
const actualPath = imagePath.join("/");
// Validate size against allowlist
if (!ALLOWED_SIZES.has(size)) {
throw error(400, "Invalid image size");
}
if (!actualPath) {
throw error(400, "Invalid image path");
}
// Sanitize path - prevent path traversal
if (actualPath.includes('..') || !PATH_PATTERN.test(actualPath)) {
throw error(400, "Invalid image path");
}
try {
const imageUrl = `${TMDB_IMAGE_URL}/${size}${actualPath.startsWith("/") ? actualPath : "/" + actualPath}`;
const response = await fetch(imageUrl);
if (!response.ok) {
throw error(response.status, "Failed to fetch image");
}
const contentType = response.headers.get("Content-Type");
const headers = new Headers();
headers.set("Content-Type", contentType || "image/jpeg");
headers.set("Cache-Control", "public, max-age=31536000");
return new Response(response.body, {
status: 200,
headers,
});
} catch (err) {
if (err && typeof err === 'object' && 'status' in err) {
throw err; // Re-throw SvelteKit errors
}
console.error("Image proxy error:", err);
throw error(500, "Failed to load image");
}
}
================================================
FILE: src/routes/api/movies/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
import type { TMDBResponse, TMDBMovie } from "$lib/types/tmdb";
export async function GET({ fetch, url }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "TMDB API is not configured. Please add TMDB_API_KEY to your .env file."
}, { status: 200 });
}
const page = url.searchParams.get("page") || "1";
const sort = url.searchParams.get("sort") || "trending";
const genre = url.searchParams.get("genre");
const year = url.searchParams.get("year");
try {
let apiUrl: string;
const baseParams = `api_key=${TMDB_API_KEY}&language=en-US&page=${page}&vote_average.gte=0.1`;
switch (sort) {
case "trending":
apiUrl = `${TMDB_API_URL}/trending/movie/week?${baseParams}`;
break;
case "popular":
apiUrl = `${TMDB_API_URL}/movie/popular?${baseParams}`;
break;
case "top_rated":
apiUrl = `${TMDB_API_URL}/movie/top_rated?${baseParams}`;
break;
case "now_playing":
apiUrl = `${TMDB_API_URL}/movie/now_playing?${baseParams}`;
break;
case "upcoming":
apiUrl = `${TMDB_API_URL}/movie/upcoming?${baseParams}`;
break;
default:
apiUrl = `${TMDB_API_URL}/discover/movie?${baseParams}`;
}
if (genre || year) {
apiUrl = `${TMDB_API_URL}/discover/movie?${baseParams}`;
if (genre) apiUrl += `&with_genres=${genre}`;
if (year) apiUrl += `&primary_release_year=${year}`;
}
const response = await fetch(apiUrl);
if (response.status === 401) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "Invalid TMDB API key. Please check your TMDB_API_KEY in .env file."
}, { status: 200 });
}
if (!response.ok) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: `Failed to fetch movies (${response.status})`
}, { status: 200 });
}
const data = await response.json() as TMDBResponse;
data.results = data.results.filter((movie) => movie.vote_average > 0);
return json(data);
} catch (err) {
console.error("Error fetching movies:", err);
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "Failed to fetch movies. Please try again later."
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/movies/trending/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
export async function GET({ fetch }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
results: [],
error: "TMDB API is not configured"
}, { status: 200 });
}
try {
const response = await fetch(
`${TMDB_API_URL}/trending/movie/week?api_key=${TMDB_API_KEY}&language=en-US`,
);
if (!response.ok) {
return json({
results: [],
error: `Failed to fetch trending movies (${response.status})`
}, { status: 200 });
}
const data = await response.json();
return json(data);
} catch (err) {
console.error("Error fetching trending movies:", err);
return json({
results: [],
error: "Failed to fetch trending movies"
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/providers/+server.ts
================================================
import { json } from "@sveltejs/kit";
import { env } from "$env/dynamic/private";
// Validate that URLs are HTTPS and from expected patterns
function validateProviderUrl(url: string | undefined): string | null {
if (!url) return null;
try {
const parsed = new URL(url);
// Only allow HTTPS URLs
if (parsed.protocol !== 'https:') {
return null;
}
return url;
} catch {
return null;
}
}
export async function GET() {
return json({
vidsrc: validateProviderUrl(env.VIDSRC_BASE_URL),
vidlink: validateProviderUrl(env.VIDLINK_BASE_URL),
movies111: validateProviderUrl(env.MOVIES111_BASE_URL),
embed2: validateProviderUrl(env.EMBED2_BASE_URL),
});
}
================================================
FILE: src/routes/api/release-info/[type]/[id]/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
interface ReleaseDate {
certification: string;
iso_639_1?: string;
release_date: string;
type: number;
note?: string;
}
interface ReleaseDateResult {
iso_3166_1: string;
release_dates: ReleaseDate[];
}
interface WatchProviderRegion {
flatrate?: Array<{ provider_name: string }>;
rent?: Array<{ provider_name: string }>;
buy?: Array<{ provider_name: string }>;
}
export async function GET({ params, fetch }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
releaseType: "Unknown Quality",
certifications: {},
});
}
const { type, id } = params;
const mediaType = type === "tv" ? "tv" : "movie";
try {
const [releaseDatesResponse, watchProvidersResponse] = await Promise.all([
fetch(`${TMDB_API_URL}/${mediaType}/${id}/release_dates?api_key=${TMDB_API_KEY}`),
fetch(`${TMDB_API_URL}/${mediaType}/${id}/watch/providers?api_key=${TMDB_API_KEY}`),
]);
if (!releaseDatesResponse.ok || !watchProvidersResponse.ok) {
return json({
releaseType: "Unknown Quality",
certifications: {},
});
}
const releaseDatesData = await releaseDatesResponse.json();
const watchProvidersData = await watchProvidersResponse.json();
const currentUtcDate = new Date(
Date.UTC(
new Date().getUTCFullYear(),
new Date().getUTCMonth(),
new Date().getUTCDate(),
),
);
const releases: ReleaseDate[] = releaseDatesData.results?.flatMap(
(result: ReleaseDateResult) => result.release_dates,
) || [];
// Extract certifications
const certifications: Record = {};
releaseDatesData.results?.forEach((result: ReleaseDateResult) => {
const certificationEntry = result.release_dates.find(
(release) => release.certification,
);
if (certificationEntry) {
certifications[result.iso_3166_1] = certificationEntry.certification;
}
});
// Check release types
const isDigitalRelease = releases.some(
(release) =>
[4, 6].includes(release.type) &&
new Date(release.release_date).getTime() <= currentUtcDate.getTime(),
);
const isInTheaters = releases.some((release) => {
const releaseDate = new Date(release.release_date);
return release.type === 3 && releaseDate.getTime() <= currentUtcDate.getTime();
});
const hasFutureRelease = releases.some(
(release) => new Date(release.release_date).getTime() > currentUtcDate.getTime(),
);
const availableRegions = Object.keys(watchProvidersData.results || {});
const isStreamingAvailable = availableRegions.some(
(region) => (watchProvidersData.results?.[region]?.flatrate || []).length > 0,
);
const isRentalOrPurchaseAvailable = availableRegions.some((region: string) => {
const data = watchProvidersData.results?.[region] as WatchProviderRegion | undefined;
const rentProviders = data?.rent || [];
const buyProviders = data?.buy || [];
return rentProviders.length > 0 || buyProviders.length > 0;
});
// Determine release type
let releaseType: string;
if (isInTheaters && !isStreamingAvailable && !isDigitalRelease) {
releaseType = "Cam";
} else if (isStreamingAvailable || isDigitalRelease) {
releaseType = "HD";
} else if (hasFutureRelease && !isInTheaters) {
releaseType = "Not Released Yet";
} else if (isRentalOrPurchaseAvailable) {
releaseType = "Rental/Buy Available";
} else {
releaseType = "Unknown Quality";
}
return json({ releaseType, certifications });
} catch (err) {
console.error("Error fetching release info:", err);
return json({
releaseType: "Unknown Quality",
certifications: {},
});
}
}
================================================
FILE: src/routes/api/tv/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
import type { TMDBResponse, TMDBTVShow } from "$lib/types/tmdb";
export async function GET({ fetch, url }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "TMDB API is not configured. Please add TMDB_API_KEY to your .env file."
}, { status: 200 });
}
const page = url.searchParams.get("page") || "1";
const sort = url.searchParams.get("sort") || "trending";
const genre = url.searchParams.get("genre");
const year = url.searchParams.get("year");
try {
let apiUrl: string;
const baseParams = `api_key=${TMDB_API_KEY}&language=en-US&page=${page}&vote_average.gte=0.1`;
switch (sort) {
case "trending":
apiUrl = `${TMDB_API_URL}/trending/tv/week?${baseParams}`;
break;
case "popular":
apiUrl = `${TMDB_API_URL}/tv/popular?${baseParams}`;
break;
case "top_rated":
apiUrl = `${TMDB_API_URL}/tv/top_rated?${baseParams}`;
break;
case "on_the_air":
apiUrl = `${TMDB_API_URL}/tv/on_the_air?${baseParams}`;
break;
case "airing_today":
apiUrl = `${TMDB_API_URL}/tv/airing_today?${baseParams}`;
break;
default:
apiUrl = `${TMDB_API_URL}/discover/tv?${baseParams}`;
}
if (genre || year) {
apiUrl = `${TMDB_API_URL}/discover/tv?${baseParams}`;
if (genre) apiUrl += `&with_genres=${genre}`;
if (year) apiUrl += `&first_air_date_year=${year}`;
}
const response = await fetch(apiUrl);
if (response.status === 401) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "Invalid TMDB API key. Please check your TMDB_API_KEY in .env file."
}, { status: 200 });
}
if (!response.ok) {
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: `Failed to fetch TV shows (${response.status})`
}, { status: 200 });
}
const data = await response.json() as TMDBResponse;
data.results = data.results.filter((show) => show.vote_average > 0);
return json(data);
} catch (err) {
console.error("Error fetching TV shows:", err);
return json({
results: [],
total_pages: 0,
total_results: 0,
page: 1,
error: "Failed to fetch TV shows. Please try again later."
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/tv/[id]/season/[season]/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
interface Episode {
id: number;
name: string;
overview: string;
episode_number: number;
air_date: string;
still_path: string | null;
vote_average: number;
vote_count: number;
runtime: number | null;
}
interface SeasonResponse {
episodes: Episode[];
}
export async function GET({ params, fetch }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
episodes: [],
error: "TMDB API is not configured"
}, { status: 200 });
}
const { id, season } = params;
try {
const response = await fetch(
`${TMDB_API_URL}/tv/${id}/season/${season}?api_key=${TMDB_API_KEY}&language=en-US`,
);
if (!response.ok) {
return json({
episodes: [],
error: `Failed to fetch season details (${response.status})`
}, { status: 200 });
}
const data: SeasonResponse = await response.json();
const currentDate = new Date();
const episodes = data.episodes.filter((episode: Episode) => {
const airDate = new Date(episode.air_date);
return airDate <= currentDate;
});
return json({ episodes });
} catch (err) {
console.error("Error fetching season episodes:", err);
return json({
episodes: [],
error: "Failed to fetch season episodes"
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/tv/[id]/seasons/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
export async function GET({ params, fetch }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
seasons: [],
error: "TMDB API is not configured"
}, { status: 200 });
}
const { id } = params;
try {
const response = await fetch(
`${TMDB_API_URL}/tv/${id}?api_key=${TMDB_API_KEY}&language=en-US`,
);
if (!response.ok) {
return json({
seasons: [],
error: `Failed to fetch TV show details (${response.status})`
}, { status: 200 });
}
const data = await response.json();
return json({ seasons: data.seasons });
} catch (err) {
console.error("Error fetching TV show seasons:", err);
return json({
seasons: [],
error: "Failed to fetch TV show seasons"
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/tv/trending/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { TMDB_API_KEY, TMDB_API_URL } from "$env/static/private";
export async function GET({ fetch }: RequestEvent) {
if (!TMDB_API_KEY || !TMDB_API_URL) {
return json({
results: [],
error: "TMDB API is not configured"
}, { status: 200 });
}
try {
const response = await fetch(
`${TMDB_API_URL}/trending/tv/week?api_key=${TMDB_API_KEY}&language=en-US`,
);
if (!response.ok) {
return json({
results: [],
error: `Failed to fetch trending TV shows (${response.status})`
}, { status: 200 });
}
const data = await response.json();
return json(data);
} catch (err) {
console.error("Error fetching trending TV shows:", err);
return json({
results: [],
error: "Failed to fetch trending TV shows"
}, { status: 200 });
}
}
================================================
FILE: src/routes/api/users/search/+server.ts
================================================
import { error, json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { prisma } from "$lib/server/prisma";
function escapeLikePattern(pattern: string): string {
return pattern.replace(/[%_\\]/g, '\\$&');
}
export async function GET({ url, locals }: RequestEvent) {
if (!locals.user) {
throw error(401, "Unauthorized");
}
const query = url.searchParams.get("q");
if (!query) {
throw error(400, "Query parameter is required");
}
if (query.length > 50) {
throw error(400, "Query too long");
}
try {
const escapedQuery = escapeLikePattern(query);
const users = await prisma.user.findMany({
where: {
username: {
contains: escapedQuery,
},
NOT: {
id: locals.user.id,
},
},
select: {
id: true,
username: true,
},
orderBy: {
username: 'asc',
},
take: 5,
});
return json(users);
} catch (err) {
console.error("Error searching users:", err);
throw error(500, "Failed to search users");
}
}
================================================
FILE: src/routes/api/watchlist/+server.ts
================================================
import { json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { watchlistService } from "$lib/server/services/watchlist";
import { handleDatabaseError } from "$lib/server/services/db-error";
import { z } from "zod";
const mediaTypeSchema = z.enum(["movie", "tv"]);
const addToWatchlistSchema = z.object({
mediaId: z.number().int().positive(),
mediaType: mediaTypeSchema,
title: z.string().min(1).max(500),
posterPath: z.string().max(500).nullable().optional(),
voteAverage: z.number().min(0).max(10).optional().default(0),
});
const removeFromWatchlistSchema = z.object({
mediaId: z.number().int().positive(),
mediaType: mediaTypeSchema,
});
export async function GET({ locals }: RequestEvent) {
if (!locals.user) {
return json({ error: "Unauthorized" }, { status: 401 });
}
try {
const items = await watchlistService.getWatchlist(locals.user.id);
const total = await watchlistService.getWatchlistCount(locals.user.id);
return json({ items, total });
} catch (err) {
return handleDatabaseError(err, "fetch watchlist");
}
}
export async function POST({ request, locals }: RequestEvent) {
if (!locals.user) {
return json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const validation = addToWatchlistSchema.safeParse(body);
if (!validation.success) {
return json(
{ error: "Invalid request data", details: validation.error.flatten() },
{ status: 400 },
);
}
const { mediaId, mediaType, title, posterPath, voteAverage } = validation.data;
const watchlistItem = await watchlistService.addToWatchlist(
locals.user.id,
mediaId,
mediaType,
title,
posterPath ?? null,
voteAverage,
);
return json(watchlistItem);
} catch (err) {
return handleDatabaseError(err, "add to watchlist");
}
}
export async function DELETE({ request, locals }: RequestEvent) {
if (!locals.user) {
return json({ error: "Unauthorized" }, { status: 401 });
}
try {
const body = await request.json();
const validation = removeFromWatchlistSchema.safeParse(body);
if (!validation.success) {
return json(
{ error: "Invalid request data", details: validation.error.flatten() },
{ status: 400 },
);
}
const { mediaId, mediaType } = validation.data;
await watchlistService.removeFromWatchlist(
locals.user.id,
mediaId,
mediaType,
);
return json({ message: "Item removed from watchlist" });
} catch (err) {
return handleDatabaseError(err, "remove from watchlist");
}
}
================================================
FILE: src/routes/api/watchlist/check/+server.ts
================================================
import { error, json } from "@sveltejs/kit";
import type { RequestEvent } from "@sveltejs/kit";
import { watchlistService } from "$lib/server/services/watchlist";
import { isDatabaseConnectionError } from "$lib/server/services/db-error";
export async function GET({ url, locals }: RequestEvent) {
if (!locals.user) {
throw error(401, "Unauthorized");
}
const mediaId = parseInt(url.searchParams.get("mediaId") || "");
const mediaType = url.searchParams.get("mediaType");
if (isNaN(mediaId) || !mediaType || !["movie", "tv"].includes(mediaType)) {
throw error(400, "Invalid mediaId or mediaType");
}
try {
const inWatchlist = await watchlistService.isInWatchlist(
locals.user.id,
mediaId,
mediaType as "movie" | "tv"
);
return json({ inWatchlist });
} catch (err) {
if (isDatabaseConnectionError(err)) {
console.error("Database unavailable:", err instanceof Error ? err.message : 'Unknown error');
throw error(503, "Service temporarily unavailable");
}
console.error("Error checking watchlist:", err);
throw error(500, "Failed to check watchlist");
}
}
================================================
FILE: src/routes/dmca/+page.svelte
================================================
DMCA Notice
This site does not store any files on our server, we only linked to the media which is hosted on 3rd party services.
About Our Service
This site is a search and indexing service that:
- Provides links to content hosted on third-party services
- Does not host or upload any media files
- Does not have control over third-party content
- Functions as a search engine for streaming content
Third-Party Content
All media content displayed on this site is hosted by and streamed from third-party services. We:
- Do not upload or host any media files
- Do not control the content on third-party services
- Only provide indexing and search functionality
- Link to publicly available content from various sources
Copyright Claims
If you find content that infringes your copyright:
- Contact the hosting service directly where the content is stored
- Request removal from the source hosting the content
- Once removed from the source, it will no longer be accessible through our service
================================================
FILE: src/routes/login/+page.svelte
================================================
================================================
FILE: src/routes/media/[id]/+page.server.ts
================================================
import { error } from "@sveltejs/kit";
import type { ServerLoad } from "@sveltejs/kit";
import { TMDBService } from "$lib/services/tmdb";
import type { TMDBMovie, TMDBTVShow } from "$lib/types/tmdb";
interface PageData {
media: TMDBMovie | TMDBTVShow;
type: "movie" | "tv";
season?: number;
episode?: number;
}
export const load: ServerLoad = async ({ params, url }) => {
const tmdb = new TMDBService();
const mediaType = url.searchParams.get("type") || "movie";
const season = url.searchParams.get("season");
const episode = url.searchParams.get("episode");
const id = parseInt(params.id as string);
if (isNaN(id)) {
throw error(400, "Invalid media ID");
}
try {
if (mediaType === "movie") {
const movie = await tmdb.getMovieDetails(id);
return {
media: movie,
type: "movie",
} satisfies PageData;
} else {
const show = await tmdb.getTVShowDetails(id);
return {
media: show,
type: "tv",
season: season ? parseInt(season) : 1,
episode: episode ? parseInt(episode) : 1,
} satisfies PageData;
}
} catch (e) {
console.error("Failed to load media details:", e);
throw error(500, "Failed to load media details");
}
};
================================================
FILE: src/routes/media/[id]/+page.svelte
================================================
{#if media?.backdrop_path}
{/if}
{mediaType === 'tv' ? 'TV Series' : 'Movie'}
{#if mediaType === 'tv'}
{seasonCount} {seasonCount === 1 ? 'Season' : 'Seasons'} • {episodeCount} {episodeCount === 1 ? 'Episode' : 'Episodes'}
{/if}
{media.title || media.name}
{#if mediaType === 'tv'}
{#if selectedSeason && selectedEpisode}
{/if}
{/if}
{formatDate(media.release_date || media.first_air_date)}
{#if media.runtime}
•
{media.runtime} min
{/if}
•
{media.vote_average.toFixed(1)}
{media.overview}
{#if media.genres?.length}
{#each media.genres as genre}
{genre.name}
{/each}
{/if}
Comments
================================================
FILE: src/routes/movies/+page.server.ts
================================================
import type { ServerLoad } from "@sveltejs/kit";
import { TMDBService } from "$lib/services/tmdb";
import type { FilterOptions } from "$lib/types/filters";
import { parseQueryString } from "$lib/types/filters";
const tmdbService = new TMDBService();
export const load: ServerLoad = async ({ url }) => {
try {
const filters = parseQueryString(url.search);
const genres = await tmdbService.getMovieGenres();
const {
results: movies,
total_pages: totalPages,
total_results: totalResults,
} = await fetchFilteredMovies(filters);
return {
movies,
genres,
totalPages: Math.min(totalPages, 500),
totalResults,
};
} catch (error) {
console.error("Failed to load movies:", error);
return {
movies: [],
genres: [],
totalPages: 0,
totalResults: 0,
error: "Failed to load movies",
};
}
};
async function fetchFilteredMovies(filters: FilterOptions) {
const {
query,
genres,
year,
rating,
sortBy = "popularity",
sortOrder = "desc",
page = 1,
} = filters;
if (query) {
return tmdbService.searchMovies(query, page);
}
const params: Record = {
page,
sort_by: `${sortBy}.${sortOrder}`,
"vote_count.gte": 100,
};
if (genres && genres.length > 0) {
params.with_genres = genres.join(",");
}
if (year) {
params.primary_release_year = year;
}
if (rating) {
params["vote_average.gte"] = rating;
}
params.with_original_language = "en";
return tmdbService.discoverMovies(params);
}
================================================
FILE: src/routes/movies/+page.svelte
================================================
Movies
{#if selectedMovie}
{selectedMovie.title}
{/if}
{#if loading && movies.length === 0}
{:else if error}
{error}
{:else if movies.length === 0}
No movies found matching your criteria
{:else}
{#each movies as movie (movie.id)}
handleMovieClick(movie)}
on:keydown={(e) => e.key === 'Enter' && handleMovieClick(movie)}
role="button"
tabindex="0"
>
{/each}
{#if page < totalPages}
{/if}
{/if}
================================================
FILE: src/routes/register/+page.svelte
================================================
================================================
FILE: src/routes/reset-password/+page.svelte
================================================
Reset Password
Enter your username or email to receive password reset instructions
================================================
FILE: src/routes/reset-password/[token]/+page.svelte
================================================
Set New Password
Please choose a strong password that includes uppercase and lowercase letters, numbers, and is at least 8 characters long.
================================================
FILE: src/routes/search/+page.server.ts
================================================
import type { PageServerLoad } from "./$types";
import { TMDBService } from "$lib/services/tmdb";
const tmdb = new TMDBService();
export const load = (async ({ url }) => {
const query = url.searchParams.get("query");
const page = parseInt(url.searchParams.get("page") || "1");
if (!query) {
return { results: [], totalPages: 0 };
}
const data = await tmdb.searchMulti(query, page);
const results = data.results.filter(
(item) =>
(item.media_type === "movie" || item.media_type === "tv") &&
item.poster_path &&
item.vote_average > 0
);
return {
results,
totalPages: Math.min(data.total_pages, 500),
};
}) satisfies PageServerLoad;
================================================
FILE: src/routes/search/+page.svelte
================================================
Search
{#if !searchQuery}
Enter a search term
{:else if results.length === 0}
No results found
{:else}
{#if mounted}
{#each results as item (item.id)}
{/each}
{/if}
{#if totalPages > 1}
{#if parseInt($page.url.searchParams.get('page') || '1') > 1}
Previous
{/if}
Page {$page.url.searchParams.get('page') || '1'} of {totalPages}
{#if parseInt($page.url.searchParams.get('page') || '1') < totalPages}
Next
{/if}
{/if}
{/if}
================================================
FILE: src/routes/tv/+page.server.ts
================================================
import type { ServerLoad } from "@sveltejs/kit";
import { TMDBService } from "$lib/services/tmdb";
import type { FilterOptions } from "$lib/types/filters";
import { parseQueryString } from "$lib/types/filters";
const tmdbService = new TMDBService();
export const load: ServerLoad = async ({ url }) => {
try {
const filters = parseQueryString(url.search);
const genres = await tmdbService.getTVGenres();
const {
results: shows,
total_pages: totalPages,
total_results: totalResults,
} = await fetchFilteredShows(filters);
return {
shows,
genres,
totalPages: Math.min(totalPages, 500),
totalResults,
};
} catch (error) {
console.error("Failed to load TV shows:", error);
return {
shows: [],
genres: [],
totalPages: 0,
totalResults: 0,
error: "Failed to load TV shows",
};
}
};
async function fetchFilteredShows(filters: FilterOptions) {
const {
query,
genres,
year,
rating,
sortBy = "popularity",
sortOrder = "desc",
page = 1,
} = filters;
if (query) {
return tmdbService.searchTVShows(query, page);
}
const params: Record = {
page,
sort_by: `${sortBy}.${sortOrder}`,
"vote_count.gte": 100,
};
if (genres && genres.length > 0) {
params.with_genres = genres.join(",");
}
if (year) {
params.first_air_date_year = year;
}
if (rating) {
params["vote_average.gte"] = rating;
}
params.with_original_language = "en";
params.include_null_first_air_dates = false;
params.screened_theatrically = true;
params["with_status"] = "0,2,3";
return tmdbService.discoverTVShows(params);
}
================================================
FILE: src/routes/tv/+page.svelte
================================================
TV Shows
{#if selectedShow && selectedSeason && selectedEpisode}
{selectedShow.name}
Season {selectedSeason} Episode {selectedEpisode}
{/if}
{#if loading && shows.length === 0}
{:else if error}
{error}
{:else if shows.length === 0}
No TV shows found matching your criteria
{:else}
{#each shows as show (show.id)}
handleShowClick(show)}
on:keydown={(e) => e.key === 'Enter' && handleShowClick(show)}
role="button"
tabindex="0"
>
{/each}
{#if page < totalPages}
{/if}
{/if}
{#if showEpisodeModal && selectedShow}
Select Episode
Seasons
{#each seasons as season}
{/each}
{#if selectedSeason && episodes.length > 0}
Episodes
{#each episodes as episode}
{/each}
{/if}
{/if}
================================================
FILE: src/routes/watchlist/+page.server.ts
================================================
import { error } from "@sveltejs/kit";
import type { ServerLoad } from "@sveltejs/kit";
import { watchlistService } from "$lib/server/services/watchlist";
import { TMDBService } from "$lib/services/tmdb";
export const load: ServerLoad = async ({ locals }) => {
if (!locals.user) {
throw error(401, "Unauthorized");
}
const tmdbService = new TMDBService();
try {
const watchlist = await watchlistService.getWatchlist(locals.user.id);
const mediaDetails = await Promise.all(
watchlist.map(async (item) => {
try {
if (item.mediaType === "movie") {
const movie = await tmdbService.getMovieDetails(item.mediaId);
return {
...movie,
mediaType: "movie" as const,
addedAt: item.addedAt,
};
} else {
const show = await tmdbService.getTVShowDetails(item.mediaId);
return {
...show,
mediaType: "tv" as const,
addedAt: item.addedAt,
};
}
} catch (error) {
console.error(
`Failed to fetch details for ${item.mediaType} ${item.mediaId}:`,
error,
);
return null;
}
}),
);
const validMediaDetails = mediaDetails
.filter((item): item is NonNullable => item !== null)
.sort(
(a, b) => new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(),
);
return {
watchlistItems: validMediaDetails,
};
} catch (e) {
console.error("Failed to load watchlist:", e);
throw error(500, "Failed to load watchlist");
}
};
================================================
FILE: src/routes/watchlist/+page.svelte
================================================
My Watchlist
{#if loading}
{:else if error}
{error}
{:else if items.length === 0}
Your watchlist is empty
Add movies or TV shows to watch later!
{:else}
{#each items as item (item.id)}
{/each}
{/if}
================================================
FILE: svelte.config.js
================================================
import adapter from "@sveltejs/adapter-node";
import { preprocessMeltUI } from "@melt-ui/pp";
import sequence from "svelte-sequential-preprocessor";
import preprocess from "svelte-preprocess";
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
out: 'build',
precompress: true,
envPrefix: 'APP_',
polyfill: true,
external: [],
}),
csrf: {
checkOrigin: true,
},
},
preprocess: sequence([
preprocess({
postcss: true,
}),
preprocessMeltUI(),
]),
};
export default config;
================================================
FILE: tailwind.config.js
================================================
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {
colors: {
primary: {
50: "#f0f9ff",
100: "#e0f2fe",
200: "#bae6fd",
300: "#7dd3fc",
400: "#38bdf8",
500: "#0ea5e9",
600: "#0284c7",
700: "#0369a1",
800: "#075985",
900: "#0c4a6e",
950: "#082f49",
},
},
animation: {
"spin-slow": "spin 3s linear infinite",
},
transitionProperty: {
"max-height": "max-height",
},
},
},
plugins: [
require("@tailwindcss/typography"),
require("@tailwindcss/forms"),
require("@tailwindcss/aspect-ratio"),
],
};
================================================
FILE: tsconfig.json
================================================
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
},
"include": [
".svelte-kit/ambient.d.ts",
".svelte-kit/types/**/$types.d.ts",
"vite.config.ts",
"src/**/*.js",
"src/**/*.ts",
"src/**/*.svelte",
"tests/**/*.js",
"tests/**/*.ts",
"tests/**/*.svelte"
]
}
================================================
FILE: vite.config.ts
================================================
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [sveltekit()],
test: {
environment: "node",
include: ["src/**/*.test.ts"],
},
});